Compare commits

..
Author SHA1 Message Date
mbecker20 c638e3876e deploy 2.0.0-dev-102 2025-12-18 14:52:04 -08:00
mbecker20 fc4ea40b9c task links to configs / secrets 2025-12-18 13:43:16 -08:00
mbecker20 62b84328d8 link attached configs / secrets in status 2025-12-18 13:29:38 -08:00
mbecker20 a5ee0de394 tweaks 2025-12-18 02:32:08 -08:00
mbecker20 bf0ab26d22 improve swarm deployments 2025-12-18 01:36:31 -08:00
mbecker20 0dd2ade574 fix config / secret rotation 2025-12-17 12:59:08 -08:00
mbecker20 f0172c2721 move inspect to swarm tabs top level 2025-12-15 22:55:37 -08:00
mbecker20 363d330868 similar but inherently limited swarm secret management 2025-12-15 22:49:45 -08:00
mbecker20 2843711796 swarm config create / edit / delete 2025-12-15 17:04:03 -08:00
mbecker20 4f044ccde8 Service aware config rotation 2025-12-14 14:42:53 -08:00
mbecker20 9be21b54bd fix deployment / stack toml when swarm attached 2025-12-14 00:06:12 -08:00
mbecker20 057ecbcfef add load passkey frontend indicator screen 2025-12-13 23:43:13 -08:00
mbecker20 e91d63f262 deploy 2.0.0-dev-101 2025-12-13 18:37:39 -08:00
mbecker20 3b799edb30 improve the swarm tables 2025-12-13 18:37:14 -08:00
mbecker20 41c7faf48d add resources tab to swarms 2025-12-13 11:21:24 -08:00
mbecker20 e1d745aee0 status colors for swarm resources 2025-12-13 11:12:26 -08:00
mbecker20 01df3add41 Fix task state check 2025-12-13 01:01:22 -08:00
mbecker20 577973841b better api / onboarding key shape 2025-12-12 23:59:42 -08:00
mbecker20 3e5311f413 user profile mobile 2025-12-12 18:30:00 -08:00
mbecker20 46e99783f2 deploy 2.0.0-dev-100 2025-12-12 18:00:28 -08:00
mbecker20 a6590804a7 skip 2fa for third party logins 2025-12-12 18:00:03 -08:00
mbecker20 bd752b7c72 deploy 2.0.0-dev-99 2025-12-12 17:08:45 -08:00
mbecker20 de83b3a5a0 user link multiple primary login methods 2025-12-12 17:08:03 -08:00
mbecker20 c91963fdbb fix table_borders in cli config 2025-12-11 01:03:43 -08:00
97 changed files with 6133 additions and 2165 deletions
Generated
+23 -43
View File
@@ -976,7 +976,7 @@ dependencies = [
[[package]]
name = "cache"
version = "2.0.0-dev-98"
version = "2.0.0-dev-102"
dependencies = [
"anyhow",
"tokio",
@@ -1168,7 +1168,7 @@ dependencies = [
[[package]]
name = "command"
version = "2.0.0-dev-98"
version = "2.0.0-dev-102"
dependencies = [
"komodo_client",
"shlex",
@@ -1195,7 +1195,7 @@ checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d"
[[package]]
name = "config"
version = "2.0.0-dev-98"
version = "2.0.0-dev-102"
dependencies = [
"colored",
"indexmap 2.12.1",
@@ -1512,20 +1512,6 @@ dependencies = [
"syn",
]
[[package]]
name = "dashmap"
version = "6.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf"
dependencies = [
"cfg-if",
"crossbeam-utils",
"hashbrown 0.14.5",
"lock_api",
"once_cell",
"parking_lot_core",
]
[[package]]
name = "data-encoding"
version = "2.9.0"
@@ -1534,7 +1520,7 @@ checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476"
[[package]]
name = "database"
version = "2.0.0-dev-98"
version = "2.0.0-dev-102"
dependencies = [
"anyhow",
"async-compression",
@@ -1847,7 +1833,7 @@ dependencies = [
[[package]]
name = "encoding"
version = "2.0.0-dev-98"
version = "2.0.0-dev-102"
dependencies = [
"anyhow",
"bytes",
@@ -1889,7 +1875,7 @@ dependencies = [
[[package]]
name = "environment"
version = "2.0.0-dev-98"
version = "2.0.0-dev-102"
dependencies = [
"anyhow",
"formatting",
@@ -1899,7 +1885,7 @@ dependencies = [
[[package]]
name = "environment_file"
version = "2.0.0-dev-98"
version = "2.0.0-dev-102"
dependencies = [
"thiserror 2.0.17",
]
@@ -2025,7 +2011,7 @@ dependencies = [
[[package]]
name = "formatting"
version = "2.0.0-dev-98"
version = "2.0.0-dev-102"
dependencies = [
"serror",
]
@@ -2191,7 +2177,7 @@ dependencies = [
[[package]]
name = "git"
version = "2.0.0-dev-98"
version = "2.0.0-dev-102"
dependencies = [
"anyhow",
"cache",
@@ -2273,12 +2259,6 @@ version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
[[package]]
name = "hashbrown"
version = "0.15.5"
@@ -2848,7 +2828,7 @@ dependencies = [
[[package]]
name = "interpolate"
version = "2.0.0-dev-98"
version = "2.0.0-dev-102"
dependencies = [
"anyhow",
"komodo_client",
@@ -2970,7 +2950,7 @@ dependencies = [
[[package]]
name = "komodo_cli"
version = "2.0.0-dev-98"
version = "2.0.0-dev-102"
dependencies = [
"anyhow",
"chrono",
@@ -2998,7 +2978,7 @@ dependencies = [
[[package]]
name = "komodo_client"
version = "2.0.0-dev-98"
version = "2.0.0-dev-102"
dependencies = [
"anyhow",
"async_timing_util",
@@ -3037,7 +3017,7 @@ dependencies = [
[[package]]
name = "komodo_core"
version = "2.0.0-dev-98"
version = "2.0.0-dev-102"
dependencies = [
"anyhow",
"arc-swap",
@@ -3057,7 +3037,6 @@ dependencies = [
"command",
"config",
"croner",
"dashmap",
"data-encoding",
"database",
"derive_variants",
@@ -3116,7 +3095,7 @@ dependencies = [
[[package]]
name = "komodo_periphery"
version = "2.0.0-dev-98"
version = "2.0.0-dev-102"
dependencies = [
"anyhow",
"arc-swap",
@@ -3130,6 +3109,7 @@ dependencies = [
"colored",
"command",
"config",
"data-encoding",
"derive_variants",
"dotenvy",
"encoding",
@@ -3238,7 +3218,7 @@ checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432"
[[package]]
name = "logger"
version = "2.0.0-dev-98"
version = "2.0.0-dev-102"
dependencies = [
"anyhow",
"komodo_client",
@@ -3540,7 +3520,7 @@ dependencies = [
[[package]]
name = "noise"
version = "2.0.0-dev-98"
version = "2.0.0-dev-102"
dependencies = [
"anyhow",
"arc-swap",
@@ -4017,7 +3997,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "periphery_client"
version = "2.0.0-dev-98"
version = "2.0.0-dev-102"
dependencies = [
"anyhow",
"derive_variants",
@@ -4407,7 +4387,7 @@ dependencies = [
[[package]]
name = "rate_limit"
version = "2.0.0-dev-98"
version = "2.0.0-dev-102"
dependencies = [
"anyhow",
"axum",
@@ -4554,7 +4534,7 @@ dependencies = [
[[package]]
name = "response"
version = "2.0.0-dev-98"
version = "2.0.0-dev-102"
dependencies = [
"anyhow",
"axum",
@@ -4803,7 +4783,7 @@ dependencies = [
[[package]]
name = "secret_file"
version = "2.0.0-dev-98"
version = "2.0.0-dev-102"
dependencies = [
"tokio",
]
@@ -5916,7 +5896,7 @@ dependencies = [
[[package]]
name = "transport"
version = "2.0.0-dev-98"
version = "2.0.0-dev-102"
dependencies = [
"anyhow",
"axum",
@@ -6139,7 +6119,7 @@ dependencies = [
[[package]]
name = "validations"
version = "2.0.0-dev-98"
version = "2.0.0-dev-102"
dependencies = [
"anyhow",
"bson",
+1 -2
View File
@@ -8,7 +8,7 @@ members = [
]
[workspace.package]
version = "2.0.0-dev-98"
version = "2.0.0-dev-102"
edition = "2024"
authors = ["mbecker20 <becker.maxh@gmail.com>"]
license = "GPL-3.0-or-later"
@@ -149,7 +149,6 @@ async-compression = { version = "0.4.35", features = ["tokio", "gzip"] }
derive_builder = "0.20.2"
comfy-table = "7.2.1"
typeshare = "1.0.4"
dashmap = "6.1.0"
wildcard = "0.3.0"
colored = "3.0.0"
bytes = "1.11.0"
+28
View File
@@ -230,9 +230,21 @@ pub async fn handle(
Execution::RemoveSwarmServices(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::CreateSwarmConfig(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::RotateSwarmConfig(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::RemoveSwarmConfigs(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::CreateSwarmSecret(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::RotateSwarmSecret(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::RemoveSwarmSecrets(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
@@ -515,10 +527,26 @@ pub async fn handle(
.execute(request)
.await
.map(|u| ExecutionResult::Single(u.into())),
Execution::CreateSwarmConfig(request) => client
.execute(request)
.await
.map(|u| ExecutionResult::Single(u.into())),
Execution::RotateSwarmConfig(request) => client
.execute(request)
.await
.map(|u| ExecutionResult::Single(u.into())),
Execution::RemoveSwarmConfigs(request) => client
.execute(request)
.await
.map(|u| ExecutionResult::Single(u.into())),
Execution::CreateSwarmSecret(request) => client
.execute(request)
.await
.map(|u| ExecutionResult::Single(u.into())),
Execution::RotateSwarmSecret(request) => client
.execute(request)
.await
.map(|u| ExecutionResult::Single(u.into())),
Execution::RemoveSwarmSecrets(request) => client
.execute(request)
.await
-1
View File
@@ -67,7 +67,6 @@ wildcard.workspace = true
arc-swap.workspace = true
serde_qs.workspace = true
colored.workspace = true
dashmap.workspace = true
tracing.workspace = true
reqwest.workspace = true
dotenvy.workspace = true
+12 -21
View File
@@ -28,14 +28,10 @@ use serror::{AddStatusCode, AddStatusCodeError, Json};
use tower_sessions::Session;
use typeshare::typeshare;
use uuid::Uuid;
use webauthn_rs::prelude::PasskeyAuthentication;
use crate::{
api::{
SESSION_KEY_PASSKEY_LOGIN, SESSION_KEY_TOTP_LOGIN,
SESSION_KEY_USER_ID, memory_session_layer,
},
auth::{
SessionPasskeyLogin, SessionTotpLogin, SessionUserId,
get_user_id_from_headers,
github::{self, client::github_oauth_client},
google::{self, client::google_oauth_client},
@@ -102,7 +98,7 @@ pub fn router() -> Router {
router = router.nest("/oidc", oidc::router())
}
router.layer(memory_session_layer(60))
router
}
async fn variant_handler(
@@ -185,11 +181,11 @@ impl Resolve<AuthArgs> for ExchangeForJwt {
"Method called in invalid context. This should not happen",
)?;
let user_id = session
.remove::<String>(SESSION_KEY_USER_ID)
.await
.context("Internal session type error")?
.context("Authentication steps must be completed before JWT can be retrieved")?;
let SessionUserId(user_id) = session
.remove(SessionUserId::KEY)
.await
.context("Internal session type error")?
.context("Authentication steps must be completed before JWT can be retrieved")?;
jwt_client().encode(user_id).map_err(Into::into)
}
@@ -216,8 +212,8 @@ impl Resolve<AuthArgs> for CompleteTotpLogin {
"Method called in invalid context. This should not happen",
)?;
let user_id = session
.get::<String>(SESSION_KEY_TOTP_LOGIN)
let SessionTotpLogin { user_id } = session
.get(SessionTotpLogin::KEY)
.await
.context("Internal session type error")?
.context(
@@ -281,10 +277,8 @@ impl Resolve<AuthArgs> for CompletePasskeyLogin {
"No webauthn provider available, invalid KOMODO_HOST config",
)?;
let (user_id, server_state) = session
.get::<(String, PasskeyAuthentication)>(
SESSION_KEY_PASSKEY_LOGIN,
)
let SessionPasskeyLogin { user_id, state } = session
.get(SessionPasskeyLogin::KEY)
.await
.context("Internal session type error")?
.context(
@@ -294,10 +288,7 @@ impl Resolve<AuthArgs> for CompletePasskeyLogin {
// The result of this call must be used to
// update the stored passkey info on database.
let update = webauthn
.finish_passkey_authentication(
&self.credential,
&server_state,
)
.finish_passkey_authentication(&self.credential, &state)
.context("Failed to validate passkey")?;
let mut passkey = get_user(&user_id)
+4
View File
@@ -154,7 +154,11 @@ pub enum ExecuteRequest {
RemoveSwarmNodes(RemoveSwarmNodes),
RemoveSwarmStacks(RemoveSwarmStacks),
RemoveSwarmServices(RemoveSwarmServices),
CreateSwarmConfig(CreateSwarmConfig),
RotateSwarmConfig(RotateSwarmConfig),
RemoveSwarmConfigs(RemoveSwarmConfigs),
CreateSwarmSecret(CreateSwarmSecret),
RotateSwarmSecret(RotateSwarmSecret),
RemoveSwarmSecrets(RemoveSwarmSecrets),
// ==== MAINTENANCE ====
+249 -7
View File
@@ -1,8 +1,9 @@
use formatting::format_serror;
use komodo_client::{
api::execute::{
RemoveSwarmConfigs, RemoveSwarmNodes, RemoveSwarmSecrets,
RemoveSwarmServices, RemoveSwarmStacks,
CreateSwarmConfig, CreateSwarmSecret, RemoveSwarmConfigs,
RemoveSwarmNodes, RemoveSwarmSecrets, RemoveSwarmServices,
RemoveSwarmStacks, RotateSwarmConfig, RotateSwarmSecret,
},
entities::{permission::PermissionLevel, swarm::Swarm},
};
@@ -11,6 +12,7 @@ use resolver_api::Resolve;
use crate::{
api::execute::ExecuteArgs,
helpers::{swarm::swarm_request, update::update_update},
monitor::update_cache_for_swarm,
permission::get_check_permissions,
};
@@ -51,7 +53,10 @@ impl Resolve<ExecuteArgs> for RemoveSwarmNodes {
)
.await
{
Ok(log) => update.logs.push(log),
Ok(log) => {
update.logs.push(log);
update_cache_for_swarm(&swarm, true).await;
}
Err(e) => update.push_error_log(
"Remove Swarm Nodes",
format_serror(
@@ -104,7 +109,10 @@ impl Resolve<ExecuteArgs> for RemoveSwarmStacks {
)
.await
{
Ok(log) => update.logs.push(log),
Ok(log) => {
update.logs.push(log);
update_cache_for_swarm(&swarm, true).await;
}
Err(e) => update.push_error_log(
"Remove Swarm Stacks",
format_serror(
@@ -155,7 +163,10 @@ impl Resolve<ExecuteArgs> for RemoveSwarmServices {
)
.await
{
Ok(log) => update.logs.push(log),
Ok(log) => {
update.logs.push(log);
update_cache_for_swarm(&swarm, true).await;
}
Err(e) => update.push_error_log(
"Remove Swarm Services",
format_serror(
@@ -171,6 +182,118 @@ impl Resolve<ExecuteArgs> for RemoveSwarmServices {
}
}
impl Resolve<ExecuteArgs> for CreateSwarmConfig {
#[instrument(
"CreateSwarmConfig",
skip_all,
fields(
id = id.to_string(),
operator = user.id,
update_id = update.id,
swarm = self.swarm,
config = self.name,
)
)]
async fn resolve(
self,
ExecuteArgs { user, update, id }: &ExecuteArgs,
) -> Result<Self::Response, Self::Error> {
let swarm = get_check_permissions::<Swarm>(
&self.swarm,
user,
PermissionLevel::Execute.into(),
)
.await?;
update_update(update.clone()).await?;
let mut update = update.clone();
match swarm_request(
&swarm.config.server_ids,
periphery_client::api::swarm::CreateSwarmConfig {
name: self.name,
data: self.data,
labels: self.labels,
template_driver: self.template_driver,
},
)
.await
{
Ok(log) => {
update.logs.push(log);
update_cache_for_swarm(&swarm, true).await;
}
Err(e) => update.push_error_log(
"Create Swarm Config",
format_serror(
&e.context("Failed to create swarm config").into(),
),
),
};
update.finalize();
update_update(update.clone()).await?;
Ok(update)
}
}
impl Resolve<ExecuteArgs> for RotateSwarmConfig {
#[instrument(
"RotateSwarmConfig",
skip_all,
fields(
id = id.to_string(),
operator = user.id,
update_id = update.id,
swarm = self.swarm,
config = self.config,
)
)]
async fn resolve(
self,
ExecuteArgs { user, update, id }: &ExecuteArgs,
) -> Result<Self::Response, Self::Error> {
let swarm = get_check_permissions::<Swarm>(
&self.swarm,
user,
PermissionLevel::Execute.into(),
)
.await?;
update_update(update.clone()).await?;
let mut update = update.clone();
match swarm_request(
&swarm.config.server_ids,
periphery_client::api::swarm::RotateSwarmConfig {
config: self.config,
data: self.data,
},
)
.await
{
Ok(logs) => {
update.logs.extend(logs);
update_cache_for_swarm(&swarm, true).await;
}
Err(e) => update.push_error_log(
"Rotate Swarm Config",
format_serror(
&e.context("Failed to rotate swarm config").into(),
),
),
};
update.finalize();
update_update(update.clone()).await?;
Ok(update)
}
}
impl Resolve<ExecuteArgs> for RemoveSwarmConfigs {
#[instrument(
"RemoveSwarmConfigs",
@@ -206,7 +329,10 @@ impl Resolve<ExecuteArgs> for RemoveSwarmConfigs {
)
.await
{
Ok(log) => update.logs.push(log),
Ok(log) => {
update.logs.push(log);
update_cache_for_swarm(&swarm, true).await;
}
Err(e) => update.push_error_log(
"Remove Swarm Configs",
format_serror(
@@ -222,6 +348,119 @@ impl Resolve<ExecuteArgs> for RemoveSwarmConfigs {
}
}
impl Resolve<ExecuteArgs> for CreateSwarmSecret {
#[instrument(
"CreateSwarmSecret",
skip_all,
fields(
id = id.to_string(),
operator = user.id,
update_id = update.id,
swarm = self.swarm,
secret = self.name,
)
)]
async fn resolve(
self,
ExecuteArgs { user, update, id }: &ExecuteArgs,
) -> Result<Self::Response, Self::Error> {
let swarm = get_check_permissions::<Swarm>(
&self.swarm,
user,
PermissionLevel::Execute.into(),
)
.await?;
update_update(update.clone()).await?;
let mut update = update.clone();
match swarm_request(
&swarm.config.server_ids,
periphery_client::api::swarm::CreateSwarmSecret {
name: self.name,
data: self.data,
driver: self.driver,
labels: self.labels,
template_driver: self.template_driver,
},
)
.await
{
Ok(log) => {
update.logs.push(log);
update_cache_for_swarm(&swarm, true).await;
}
Err(e) => update.push_error_log(
"Create Swarm Secret",
format_serror(
&e.context("Failed to create swarm secret").into(),
),
),
};
update.finalize();
update_update(update.clone()).await?;
Ok(update)
}
}
impl Resolve<ExecuteArgs> for RotateSwarmSecret {
#[instrument(
"RotateSwarmSecret",
skip_all,
fields(
id = id.to_string(),
operator = user.id,
update_id = update.id,
swarm = self.swarm,
secret = self.secret,
)
)]
async fn resolve(
self,
ExecuteArgs { user, update, id }: &ExecuteArgs,
) -> Result<Self::Response, Self::Error> {
let swarm = get_check_permissions::<Swarm>(
&self.swarm,
user,
PermissionLevel::Execute.into(),
)
.await?;
update_update(update.clone()).await?;
let mut update = update.clone();
match swarm_request(
&swarm.config.server_ids,
periphery_client::api::swarm::RotateSwarmSecret {
secret: self.secret,
data: self.data,
},
)
.await
{
Ok(logs) => {
update.logs.extend(logs);
update_cache_for_swarm(&swarm, true).await;
}
Err(e) => update.push_error_log(
"Rotate Swarm Secret",
format_serror(
&e.context("Failed to rotate swarm secret").into(),
),
),
};
update.finalize();
update_update(update.clone()).await?;
Ok(update)
}
}
impl Resolve<ExecuteArgs> for RemoveSwarmSecrets {
#[instrument(
"RemoveSwarmSecrets",
@@ -257,7 +496,10 @@ impl Resolve<ExecuteArgs> for RemoveSwarmSecrets {
)
.await
{
Ok(log) => update.logs.push(log),
Ok(log) => {
update.logs.push(log);
update_cache_for_swarm(&swarm, true).await;
}
Err(e) => update.push_error_log(
"Remove Swarm Secrets",
format_serror(
+13 -12
View File
@@ -8,7 +8,8 @@ use tower_http::{
set_header::SetResponseHeaderLayer,
};
use tower_sessions::{
Expiry, MemoryStore, SessionManagerLayer, cookie::time::Duration,
Expiry, MemoryStore, SessionManagerLayer,
cookie::{SameSite, time::Duration},
};
use crate::{
@@ -52,6 +53,7 @@ pub fn app() -> Router {
.nest("/listener", listener::router())
.nest("/ws", ws::router())
.nest("/client", ts_client::router())
.layer(memory_session_layer())
.fallback_service(serve_frontend)
.layer(cors_layer())
.layer(SetResponseHeaderLayer::overriding(
@@ -72,21 +74,20 @@ pub fn app() -> Router {
))
}
fn memory_session_layer(
expiry: i64,
) -> SessionManagerLayer<MemoryStore> {
const MEMORY_SESSION_EXPIRY_SECONDS: i64 = 60;
fn memory_session_layer() -> SessionManagerLayer<MemoryStore> {
let config = core_config();
let mut layer = SessionManagerLayer::new(MemoryStore::default())
.with_expiry(Expiry::OnInactivity(Duration::seconds(expiry)))
.with_secure(config.host.starts_with("https://"));
.with_expiry(Expiry::OnInactivity(Duration::seconds(
MEMORY_SESSION_EXPIRY_SECONDS,
)))
.with_secure(config.host.starts_with("https://"))
// Needs Lax in order for sessions to work
// accross oauth redirects.
.with_same_site(SameSite::Lax);
if let Some(domain) = core_host().and_then(|url| url.domain()) {
layer = layer.with_domain(domain);
}
layer
}
pub const SESSION_KEY_USER_ID: &str = "user-id";
pub const SESSION_KEY_TOTP_LOGIN: &str = "totp-user-id";
pub const SESSION_KEY_TOTP_ENROLLMENT: &str = "totp-enrollment";
pub const SESSION_KEY_PASSKEY_LOGIN: &str = "passkey-user-id";
pub const SESSION_KEY_PASSKEY_ENROLLMENT: &str = "passkey-enrollment";
-523
View File
@@ -1,523 +0,0 @@
use std::{collections::VecDeque, time::Instant};
use anyhow::{Context, anyhow};
use axum::{
Extension, Json, Router, extract::Path, middleware, routing::post,
};
use data_encoding::BASE32_NOPAD;
use database::hash_password;
use database::mongo_indexed::doc;
use database::mungos::{
by_id::update_one_by_id, mongodb::bson::to_bson,
};
use derive_variants::EnumVariants;
use komodo_client::entities::{random_bytes, random_string};
use komodo_client::{
api::user::*,
entities::{api_key::ApiKey, komodo_timestamp, user::User},
};
use reqwest::StatusCode;
use resolver_api::Resolve;
use response::Response;
use serde::{Deserialize, Serialize};
use serde_json::json;
use serror::{AddStatusCode, AddStatusCodeError};
use tower_sessions::Session;
use typeshare::typeshare;
use uuid::Uuid;
use webauthn_rs::prelude::PasskeyRegistration;
use crate::api::{
SESSION_KEY_PASSKEY_ENROLLMENT, SESSION_KEY_TOTP_ENROLLMENT,
memory_session_layer,
};
use crate::auth::totp::make_totp;
use crate::config::core_config;
use crate::helpers::validations::validate_api_key_name;
use crate::state::webauthn;
use crate::{
auth::auth_request, helpers::query::get_user, state::db_client,
};
use super::Variant;
pub struct UserArgs {
pub user: User,
/// Per-client session state
pub session: Option<Session>,
}
#[typeshare]
#[derive(
Serialize, Deserialize, Debug, Clone, Resolve, EnumVariants,
)]
#[args(UserArgs)]
#[response(Response)]
#[error(serror::Error)]
#[serde(tag = "type", content = "params")]
enum UserRequest {
PushRecentlyViewed(PushRecentlyViewed),
SetLastSeenUpdate(SetLastSeenUpdate),
CreateApiKey(CreateApiKey),
DeleteApiKey(DeleteApiKey),
BeginTotpEnrollment(BeginTotpEnrollment),
ConfirmTotpEnrollment(ConfirmTotpEnrollment),
UnenrollTotp(UnenrollTotp),
BeginPasskeyEnrollment(BeginPasskeyEnrollment),
ConfirmPasskeyEnrollment(ConfirmPasskeyEnrollment),
UnenrollPasskey(UnenrollPasskey),
}
pub fn router() -> Router {
Router::new()
.route("/", post(handler))
.route("/{variant}", post(variant_handler))
.layer(memory_session_layer(60))
.layer(middleware::from_fn(auth_request))
}
async fn variant_handler(
session: Session,
user: Extension<User>,
Path(Variant { variant }): Path<Variant>,
Json(params): Json<serde_json::Value>,
) -> serror::Result<axum::response::Response> {
let req: UserRequest = serde_json::from_value(json!({
"type": variant,
"params": params,
}))?;
handler(session, user, Json(req)).await
}
async fn handler(
session: Session,
Extension(user): Extension<User>,
Json(request): Json<UserRequest>,
) -> serror::Result<axum::response::Response> {
let timer = Instant::now();
let req_id = Uuid::new_v4();
debug!(
"/user request {req_id} | user: {} ({})",
user.username, user.id
);
let res = request
.resolve(&UserArgs {
user,
session: Some(session),
})
.await;
if let Err(e) = &res {
warn!("/user request {req_id} error: {:#}", e.error);
}
let elapsed = timer.elapsed();
debug!("/user request {req_id} | resolve time: {elapsed:?}");
res.map(|res| res.0)
}
const RECENTLY_VIEWED_MAX: usize = 10;
impl Resolve<UserArgs> for PushRecentlyViewed {
async fn resolve(
self,
UserArgs { user, .. }: &UserArgs,
) -> serror::Result<PushRecentlyViewedResponse> {
let user = get_user(&user.id).await?;
let (resource_type, id) = self.resource.extract_variant_id();
let field = format!("recents.{resource_type}");
let update = match user.recents.get(&resource_type) {
Some(recents) => {
let mut recents = recents
.iter()
.filter(|_id| !id.eq(*_id))
.take(RECENTLY_VIEWED_MAX - 1)
.collect::<VecDeque<_>>();
recents.push_front(id);
doc! { &field: to_bson(&recents)? }
}
None => {
doc! { &field: [id] }
}
};
update_one_by_id(
&db_client().users,
&user.id,
database::mungos::update::Update::Set(update),
None,
)
.await
.with_context(|| format!("Failed to update user '{field}'"))?;
Ok(PushRecentlyViewedResponse {})
}
}
impl Resolve<UserArgs> for SetLastSeenUpdate {
async fn resolve(
self,
UserArgs { user, .. }: &UserArgs,
) -> serror::Result<SetLastSeenUpdateResponse> {
update_one_by_id(
&db_client().users,
&user.id,
database::mungos::update::Update::Set(doc! {
"last_update_view": komodo_timestamp()
}),
None,
)
.await
.context("Failed to update user 'last_update_view'")?;
Ok(SetLastSeenUpdateResponse {})
}
}
const SECRET_LENGTH: usize = 40;
const BCRYPT_COST: u32 = 10;
impl Resolve<UserArgs> for CreateApiKey {
#[instrument(
"CreateApiKey",
skip_all,
fields(operator = user.id)
)]
async fn resolve(
self,
UserArgs { user, .. }: &UserArgs,
) -> serror::Result<CreateApiKeyResponse> {
let user = get_user(&user.id).await?;
validate_api_key_name(&self.name)
.status_code(StatusCode::BAD_REQUEST)?;
let key = format!("K-{}", random_string(SECRET_LENGTH));
let secret = format!("S-{}", random_string(SECRET_LENGTH));
let secret_hash = bcrypt::hash(&secret, BCRYPT_COST)
.context("Failed at hashing secret string")?;
let api_key = ApiKey {
name: self.name,
key: key.clone(),
secret: secret_hash,
user_id: user.id.clone(),
created_at: komodo_timestamp(),
expires: self.expires,
};
db_client()
.api_keys
.insert_one(api_key)
.await
.context("Failed to create api key on database")?;
Ok(CreateApiKeyResponse { key, secret })
}
}
impl Resolve<UserArgs> for DeleteApiKey {
#[instrument(
"DeleteApiKey",
skip_all,
fields(operator = user.id)
)]
async fn resolve(
self,
UserArgs { user, .. }: &UserArgs,
) -> serror::Result<DeleteApiKeyResponse> {
let client = db_client();
let key = client
.api_keys
.find_one(doc! { "key": &self.key })
.await
.context("Failed at database query")?
.context("No api key with key found")?;
if user.id != key.user_id {
return Err(
anyhow!("Api key does not belong to user")
.status_code(StatusCode::FORBIDDEN),
);
}
client
.api_keys
.delete_one(doc! { "key": key.key })
.await
.context("Failed to delete api key from database")?;
Ok(DeleteApiKeyResponse {})
}
}
const TOTP_ENROLLMENT_SECRET_LENGTH: usize = 20;
impl Resolve<UserArgs> for BeginTotpEnrollment {
#[instrument(
"BeginTotpEnrollment",
skip_all,
fields(operator = user.id)
)]
async fn resolve(
self,
UserArgs { user, session }: &UserArgs,
) -> serror::Result<BeginTotpEnrollmentResponse> {
for locked_username in &core_config().lock_login_credentials_for {
if *locked_username == user.username {
return Err(
anyhow!("User not allowed to enroll in TOTP 2FA.").into(),
);
}
}
let session = session.as_ref().context(
"Method called in invalid context. This should not happen",
)?;
let secret_bytes = random_bytes(TOTP_ENROLLMENT_SECRET_LENGTH);
let totp = make_totp(secret_bytes.clone(), user.id.clone())?;
let png = totp
.get_qr_base64()
.map_err(|e| anyhow::Error::msg(e))
.context("Failed to generate QR code png")?;
session
.insert(SESSION_KEY_TOTP_ENROLLMENT, secret_bytes)
.await?;
Ok(BeginTotpEnrollmentResponse {
uri: totp.get_url(),
png,
})
}
}
impl Resolve<UserArgs> for ConfirmTotpEnrollment {
#[instrument(
"ConfirmTotpEnrollment",
skip_all,
fields(operator = user.id)
)]
async fn resolve(
self,
UserArgs { user, session }: &UserArgs,
) -> serror::Result<ConfirmTotpEnrollmentResponse> {
let session = session.as_ref().context(
"Method called in invalid context. This should not happen",
)?;
let secret_bytes = session
.remove::<Vec<u8>>(SESSION_KEY_TOTP_ENROLLMENT)
.await
.context("Totp enrollment was not initiated correctly")?
.context(
"Totp enrollment was not initiated correctly or timed out",
)?;
let encoded_secret = BASE32_NOPAD.encode(&secret_bytes);
let totp = make_totp(secret_bytes, None)?;
let valid = totp
.check_current(&self.code)
.context("Failed to check code validity")?;
if !valid {
return Err(anyhow!(
"The provided code was not valid. Please try BeginTotpEnrollment flow again."
).status_code(StatusCode::BAD_REQUEST));
}
let recovery_codes =
(0..10).map(|_| random_string(20)).collect::<Vec<_>>();
let hashed_recovery_codes = recovery_codes
.iter()
.map(|code| hash_password(code))
.collect::<anyhow::Result<Vec<_>>>()
.context("Failed to generate valid recovery codes")?;
update_one_by_id(
&db_client().users,
&user.id,
doc! {
"$set": {
"totp.secret": encoded_secret,
"totp.confirmed_at": komodo_timestamp(),
"totp.recovery_codes": hashed_recovery_codes,
}
},
None,
)
.await
.context("Failed to update user totp fields on database")?;
Ok(ConfirmTotpEnrollmentResponse { recovery_codes })
}
}
impl Resolve<UserArgs> for UnenrollTotp {
#[instrument(
"UnenrollTotp",
skip_all,
fields(operator = user.id)
)]
async fn resolve(
self,
UserArgs { user, .. }: &UserArgs,
) -> serror::Result<UnenrollTotpResponse> {
update_one_by_id(
&db_client().users,
&user.id,
doc! {
"$set": {
"totp.secret": "",
"totp.confirmed_at": 0,
"totp.recovery_codes": [],
}
},
None,
)
.await
.context("Failed to clear user totp fields on database")?;
Ok(UnenrollTotpResponse {})
}
}
//
impl Resolve<UserArgs> for BeginPasskeyEnrollment {
#[instrument(
"BeginPasskeyEnrollment",
skip_all,
fields(operator = user.id)
)]
async fn resolve(
self,
UserArgs { user, session }: &UserArgs,
) -> serror::Result<BeginPasskeyEnrollmentResponse> {
for locked_username in &core_config().lock_login_credentials_for {
if *locked_username == user.username {
return Err(
anyhow!(
"User not allowed to enroll in Passkey authentication."
)
.into(),
);
}
}
let session = session.as_ref().context(
"Method called in invalid context. This should not happen",
)?;
let webauthn = webauthn().context(
"No webauthn provider available, invalid KOMODO_HOST config",
)?;
// Get two parts from this, the first is returned to the client.
// The second must stay server side and is used in confirmation flow.
let (challenge, server_state) = webauthn
.start_passkey_registration(
Uuid::new_v4(),
&user.username,
&user.username,
None,
)?;
session
.insert(
SESSION_KEY_PASSKEY_ENROLLMENT,
(&user.id, server_state),
)
.await
.context(
"Failed to store passkey enrollment state in server side client session",
)?;
Ok(challenge.into())
}
}
//
impl Resolve<UserArgs> for ConfirmPasskeyEnrollment {
#[instrument(
"ConfirmPasskeyEnrollment",
skip_all,
fields(operator = user.id)
)]
async fn resolve(
self,
UserArgs { user, session }: &UserArgs,
) -> serror::Result<ConfirmPasskeyEnrollmentResponse> {
let session = session.as_ref().context(
"Method called in invalid context. This should not happen",
)?;
let webauthn = webauthn().context(
"No webauthn provider available, invalid KOMODO_HOST config",
)?;
let (user_id, server_state) = session
.remove::<(String, PasskeyRegistration)>(
SESSION_KEY_PASSKEY_ENROLLMENT,
)
.await
.context("Passkey enrollment was not initiated correctly")?
.context(
"Passkey enrollment was not initiated correctly or timed out",
)?;
let passkey = webauthn
.finish_passkey_registration(
&self.credential.into(),
&server_state,
)
.context("Failed to finish passkey registration")?;
let passkey = to_bson(&passkey)
.context("Failed to serialize passkey to BSON")?;
let update = doc! {
"$set": {
"passkey.passkey": passkey,
"passkey.created_at": komodo_timestamp()
}
};
update_one_by_id(&db_client().users, &user_id, update, None)
.await
.context("Failed to update user passkey options on database")?;
Ok(ConfirmPasskeyEnrollmentResponse {})
}
}
//
impl Resolve<UserArgs> for UnenrollPasskey {
#[instrument(
"UnenrollPasskey",
skip_all,
fields(operator = user.id)
)]
async fn resolve(
self,
UserArgs { user, .. }: &UserArgs,
) -> serror::Result<UnenrollPasskeyResponse> {
let update = doc! {
"$set": {
"passkey.passkey": null,
"passkey.created_at": 0
}
};
update_one_by_id(&db_client().users, &user.id, update, None)
.await
.context("Failed to update user passkey options on database")?;
Ok(UnenrollPasskeyResponse {})
}
}
+97
View File
@@ -0,0 +1,97 @@
use anyhow::{Context as _, anyhow};
use database::bson::doc;
use komodo_client::{
api::user::{
CreateApiKey, CreateApiKeyResponse, DeleteApiKey,
DeleteApiKeyResponse,
},
entities::{api_key::ApiKey, komodo_timestamp, random_string},
};
use reqwest::StatusCode;
use resolver_api::Resolve;
use serror::{AddStatusCode as _, AddStatusCodeError as _};
use crate::{
helpers::{query::get_user, validations::validate_api_key_name},
state::db_client,
};
use super::UserArgs;
const SECRET_LENGTH: usize = 40;
const BCRYPT_COST: u32 = 10;
impl Resolve<UserArgs> for CreateApiKey {
#[instrument(
"CreateApiKey",
skip_all,
fields(operator = user.id)
)]
async fn resolve(
self,
UserArgs { user, .. }: &UserArgs,
) -> serror::Result<CreateApiKeyResponse> {
let user = get_user(&user.id).await?;
validate_api_key_name(&self.name)
.status_code(StatusCode::BAD_REQUEST)?;
let key = format!("K_{}_K", random_string(SECRET_LENGTH));
let secret = format!("S_{}_S", random_string(SECRET_LENGTH));
let secret_hash = bcrypt::hash(&secret, BCRYPT_COST)
.context("Failed at hashing secret string")?;
let api_key = ApiKey {
name: self.name,
key: key.clone(),
secret: secret_hash,
user_id: user.id.clone(),
created_at: komodo_timestamp(),
expires: self.expires,
};
db_client()
.api_keys
.insert_one(api_key)
.await
.context("Failed to create api key on database")?;
Ok(CreateApiKeyResponse { key, secret })
}
}
impl Resolve<UserArgs> for DeleteApiKey {
#[instrument(
"DeleteApiKey",
skip_all,
fields(operator = user.id)
)]
async fn resolve(
self,
UserArgs { user, .. }: &UserArgs,
) -> serror::Result<DeleteApiKeyResponse> {
let client = db_client();
let key = client
.api_keys
.find_one(doc! { "key": &self.key })
.await
.context("Failed at database query")?
.context("No api key with key found")?;
if user.id != key.user_id {
return Err(
anyhow!("Api key does not belong to user")
.status_code(StatusCode::FORBIDDEN),
);
}
client
.api_keys
.delete_one(doc! { "key": key.key })
.await
.context("Failed to delete api key from database")?;
Ok(DeleteApiKeyResponse {})
}
}
+367
View File
@@ -0,0 +1,367 @@
use std::{collections::VecDeque, time::Instant};
use anyhow::{Context, anyhow};
use axum::{
Extension, Json, Router, extract::Path, middleware, routing::post,
};
use database::{
bson::{doc, to_bson},
mungos::by_id::update_one_by_id,
};
use derive_variants::EnumVariants;
use komodo_client::{
api::user::*,
entities::{NoData, komodo_timestamp, user::User},
};
use reqwest::StatusCode;
use resolver_api::Resolve;
use response::Response;
use serde::{Deserialize, Serialize};
use serde_json::json;
use serror::{AddStatusCode as _, AddStatusCodeError};
use tower_sessions::Session;
use typeshare::typeshare;
use uuid::Uuid;
use crate::{
auth::auth_request,
config::core_config,
helpers::{
query::get_user,
validations::{validate_password, validate_username},
},
state::db_client,
};
use super::Variant;
mod api_key;
mod passkey;
mod totp;
pub struct UserArgs {
pub user: User,
/// Per-client session state
pub session: Option<Session>,
}
#[typeshare]
#[derive(
Debug, Clone, Serialize, Deserialize, Resolve, EnumVariants,
)]
#[args(UserArgs)]
#[response(Response)]
#[error(serror::Error)]
#[serde(tag = "type", content = "params")]
enum UserRequest {
PushRecentlyViewed(PushRecentlyViewed),
SetLastSeenUpdate(SetLastSeenUpdate),
UpdateUsername(UpdateUsername),
UpdatePassword(UpdatePassword),
CreateApiKey(CreateApiKey),
DeleteApiKey(DeleteApiKey),
BeginTotpEnrollment(BeginTotpEnrollment),
ConfirmTotpEnrollment(ConfirmTotpEnrollment),
UnenrollTotp(UnenrollTotp),
BeginPasskeyEnrollment(BeginPasskeyEnrollment),
ConfirmPasskeyEnrollment(ConfirmPasskeyEnrollment),
UnenrollPasskey(UnenrollPasskey),
BeginThirdPartyLoginLink(BeginThirdPartyLoginLink),
UnlinkLogin(UnlinkLogin),
UpdateThirdPartySkip2fa(UpdateThirdPartySkip2fa),
}
pub fn router() -> Router {
Router::new()
.route("/", post(handler))
.route("/{variant}", post(variant_handler))
.layer(middleware::from_fn(auth_request))
}
async fn variant_handler(
session: Session,
user: Extension<User>,
Path(Variant { variant }): Path<Variant>,
Json(params): Json<serde_json::Value>,
) -> serror::Result<axum::response::Response> {
let req: UserRequest = serde_json::from_value(json!({
"type": variant,
"params": params,
}))?;
handler(session, user, Json(req)).await
}
async fn handler(
session: Session,
Extension(user): Extension<User>,
Json(request): Json<UserRequest>,
) -> serror::Result<axum::response::Response> {
let timer = Instant::now();
let req_id = Uuid::new_v4();
debug!(
"/user request {req_id} | user: {} ({})",
user.username, user.id
);
let res = request
.resolve(&UserArgs {
user,
session: Some(session),
})
.await;
if let Err(e) = &res {
warn!("/user request {req_id} error: {:#}", e.error);
}
let elapsed = timer.elapsed();
debug!("/user request {req_id} | resolve time: {elapsed:?}");
res.map(|res| res.0)
}
const RECENTLY_VIEWED_MAX: usize = 10;
impl Resolve<UserArgs> for PushRecentlyViewed {
async fn resolve(
self,
UserArgs { user, .. }: &UserArgs,
) -> serror::Result<PushRecentlyViewedResponse> {
let user = get_user(&user.id).await?;
let (resource_type, id) = self.resource.extract_variant_id();
let field = format!("recents.{resource_type}");
let update = match user.recents.get(&resource_type) {
Some(recents) => {
let mut recents = recents
.iter()
.filter(|_id| !id.eq(*_id))
.take(RECENTLY_VIEWED_MAX - 1)
.collect::<VecDeque<_>>();
recents.push_front(id);
doc! { &field: to_bson(&recents)? }
}
None => {
doc! { &field: [id] }
}
};
update_one_by_id(
&db_client().users,
&user.id,
database::mungos::update::Update::Set(update),
None,
)
.await
.with_context(|| format!("Failed to update user '{field}'"))?;
Ok(PushRecentlyViewedResponse {})
}
}
impl Resolve<UserArgs> for SetLastSeenUpdate {
async fn resolve(
self,
UserArgs { user, .. }: &UserArgs,
) -> serror::Result<SetLastSeenUpdateResponse> {
update_one_by_id(
&db_client().users,
&user.id,
database::mungos::update::Update::Set(doc! {
"last_update_view": komodo_timestamp()
}),
None,
)
.await
.context("Failed to update user 'last_update_view'")?;
Ok(SetLastSeenUpdateResponse {})
}
}
//
impl Resolve<UserArgs> for UpdateUsername {
#[instrument(
"UpdateUsername",
skip_all,
fields(
operator = user.id,
new_username = self.username,
)
)]
async fn resolve(
self,
UserArgs { user, .. }: &UserArgs,
) -> serror::Result<UpdateUsernameResponse> {
check_locked(&user.username)?;
validate_username(&self.username)?;
let db = db_client();
if db
.users
.find_one(doc! { "username": &self.username })
.await
.context("Failed to query for existing users")?
.is_some()
{
return Err(anyhow!("Username already taken.").into());
}
let update = doc! { "$set": { "username": self.username } };
update_one_by_id(&db.users, &user.id, update, None)
.await
.context("Failed to update user username on database.")?;
Ok(NoData {})
}
}
//
impl Resolve<UserArgs> for UpdatePassword {
#[instrument(
"UpdatePassword",
skip_all,
fields(operator = user.id)
)]
async fn resolve(
self,
UserArgs { user, .. }: &UserArgs,
) -> serror::Result<UpdatePasswordResponse> {
check_locked(&user.username)?;
validate_password(&self.password)
.status_code(StatusCode::BAD_REQUEST)?;
db_client().set_user_password(user, &self.password).await?;
Ok(NoData {})
}
}
//
#[derive(Serialize, Deserialize)]
pub struct SessionThirdPartyLinkInfo {
pub user_id: String,
}
impl SessionThirdPartyLinkInfo {
pub const KEY: &str = "third-party-link-info";
}
impl Resolve<UserArgs> for BeginThirdPartyLoginLink {
#[instrument(
"BeginThirdPartyLoginLink",
skip_all,
fields(operator = user.id)
)]
async fn resolve(
self,
UserArgs { user, session }: &UserArgs,
) -> serror::Result<BeginThirdPartyLoginLinkResponse> {
check_locked(&user.username)?;
let session = session.as_ref().context(
"Method called in invalid context. This should not happen",
)?;
session
.insert(
SessionThirdPartyLinkInfo::KEY,
SessionThirdPartyLinkInfo {
user_id: user.id.clone(),
},
)
.await
.context(
"Failed to insert third party link info into client session",
)?;
Ok(NoData {})
}
}
impl Resolve<UserArgs> for UnlinkLogin {
#[instrument(
"UnlinkLogin",
skip_all,
fields(operator = user.id)
)]
async fn resolve(
self,
UserArgs { user, .. }: &UserArgs,
) -> serror::Result<UnlinkLoginResponse> {
check_locked(&user.username)?;
let field = match self.provider.to_lowercase().as_str() {
"local" => "linked_logins.Local",
"github" => "linked_logins.Github",
"google" => "linked_logins.Google",
"oidc" => "linked_logins.Oidc",
_ => {
return Err(
anyhow!(
"Unrecognized third party login provider: {}",
self.provider
)
.status_code(StatusCode::BAD_REQUEST),
);
}
};
let update = doc! {
"$unset": {
field: ""
}
};
update_one_by_id(&db_client().users, &user.id, update, None)
.await
.context("Failed to unlink third partly login on database")?;
Ok(NoData {})
}
}
impl Resolve<UserArgs> for UpdateThirdPartySkip2fa {
#[instrument(
"UpdateThirdPartySkip2fa",
skip_all,
fields(operator = user.id)
)]
async fn resolve(
self,
UserArgs { user, .. }: &UserArgs,
) -> serror::Result<UpdateThirdPartySkip2faResponse> {
check_locked(&user.username)?;
let update = doc! {
"$set": {
"third_party_skip_2fa": self.skip
}
};
update_one_by_id(&db_client().users, &user.id, update, None)
.await
.context("Failed to unlink third partly login on database")?;
Ok(NoData {})
}
}
fn check_locked(username: &str) -> serror::Result<()> {
for locked_username in &core_config().lock_login_credentials_for {
if locked_username == "__ALL__" || *locked_username == username {
return Err(
anyhow!("User login credentials are locked.")
.status_code(StatusCode::UNAUTHORIZED),
);
}
}
Ok(())
}
+151
View File
@@ -0,0 +1,151 @@
use anyhow::Context as _;
use database::{
bson::{doc, to_bson},
mungos::by_id::update_one_by_id,
};
use komodo_client::{
api::user::{
BeginPasskeyEnrollment, BeginPasskeyEnrollmentResponse,
ConfirmPasskeyEnrollment, ConfirmPasskeyEnrollmentResponse,
UnenrollPasskey, UnenrollPasskeyResponse,
},
entities::komodo_timestamp,
};
use resolver_api::Resolve;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use webauthn_rs::prelude::PasskeyRegistration;
use crate::state::{db_client, webauthn};
use super::UserArgs;
#[derive(Serialize, Deserialize)]
struct SessionPasskeyEnrollment {
user_id: String,
state: PasskeyRegistration,
}
impl SessionPasskeyEnrollment {
const KEY: &str = "passkey-enrollment";
}
impl Resolve<UserArgs> for BeginPasskeyEnrollment {
#[instrument(
"BeginPasskeyEnrollment",
skip_all,
fields(operator = user.id)
)]
async fn resolve(
self,
UserArgs { user, session }: &UserArgs,
) -> serror::Result<BeginPasskeyEnrollmentResponse> {
super::check_locked(&user.username)?;
let session = session.as_ref().context(
"Method called in invalid context. This should not happen",
)?;
let webauthn = webauthn().context(
"No webauthn provider available, invalid KOMODO_HOST config",
)?;
// Get two parts from this, the first is returned to the client.
// The second must stay server side and is used in confirmation flow.
let (challenge, state) = webauthn.start_passkey_registration(
Uuid::new_v4(),
&user.username,
&user.username,
None,
)?;
session
.insert(
SessionPasskeyEnrollment::KEY,
SessionPasskeyEnrollment {
user_id: user.id.clone(),
state
},
)
.await
.context(
"Failed to store passkey enrollment state in server side client session",
)?;
Ok(challenge)
}
}
//
impl Resolve<UserArgs> for ConfirmPasskeyEnrollment {
#[instrument(
"ConfirmPasskeyEnrollment",
skip_all,
fields(operator = user.id)
)]
async fn resolve(
self,
UserArgs { user, session }: &UserArgs,
) -> serror::Result<ConfirmPasskeyEnrollmentResponse> {
let session = session.as_ref().context(
"Method called in invalid context. This should not happen",
)?;
let webauthn = webauthn().context(
"No webauthn provider available, invalid KOMODO_HOST config",
)?;
let SessionPasskeyEnrollment { user_id, state } = session
.remove(SessionPasskeyEnrollment::KEY)
.await
.context("Passkey enrollment was not initiated correctly")?
.context(
"Passkey enrollment was not initiated correctly or timed out",
)?;
let passkey = webauthn
.finish_passkey_registration(&self.credential, &state)
.context("Failed to finish passkey registration")?;
let passkey = to_bson(&passkey)
.context("Failed to serialize passkey to BSON")?;
let update = doc! {
"$set": {
"passkey.passkey": passkey,
"passkey.created_at": komodo_timestamp()
}
};
update_one_by_id(&db_client().users, &user_id, update, None)
.await
.context("Failed to update user passkey options on database")?;
Ok(ConfirmPasskeyEnrollmentResponse {})
}
}
//
impl Resolve<UserArgs> for UnenrollPasskey {
#[instrument(
"UnenrollPasskey",
skip_all,
fields(operator = user.id)
)]
async fn resolve(
self,
UserArgs { user, .. }: &UserArgs,
) -> serror::Result<UnenrollPasskeyResponse> {
let update = doc! {
"$set": {
"passkey.passkey": null,
"passkey.created_at": 0
}
};
update_one_by_id(&db_client().users, &user.id, update, None)
.await
.context("Failed to update user passkey options on database")?;
Ok(UnenrollPasskeyResponse {})
}
}
+158
View File
@@ -0,0 +1,158 @@
use anyhow::{Context as _, anyhow};
use data_encoding::BASE32_NOPAD;
use database::{
bson::doc, hash_password, mungos::by_id::update_one_by_id,
};
use komodo_client::{
api::user::{
BeginTotpEnrollment, BeginTotpEnrollmentResponse,
ConfirmTotpEnrollment, ConfirmTotpEnrollmentResponse,
UnenrollTotp, UnenrollTotpResponse,
},
entities::{komodo_timestamp, random_bytes, random_string},
};
use reqwest::StatusCode;
use resolver_api::Resolve;
use serde::{Deserialize, Serialize};
use serror::AddStatusCodeError as _;
use crate::{auth::totp::make_totp, state::db_client};
use super::UserArgs;
const TOTP_ENROLLMENT_SECRET_LENGTH: usize = 20;
#[derive(Serialize, Deserialize)]
struct SessionTotpEnrollment {
secret: Vec<u8>,
}
impl SessionTotpEnrollment {
const KEY: &str = "totp-enrollment";
}
impl Resolve<UserArgs> for BeginTotpEnrollment {
#[instrument(
"BeginTotpEnrollment",
skip_all,
fields(operator = user.id)
)]
async fn resolve(
self,
UserArgs { user, session }: &UserArgs,
) -> serror::Result<BeginTotpEnrollmentResponse> {
super::check_locked(&user.username)?;
let session = session.as_ref().context(
"Method called in invalid context. This should not happen",
)?;
let secret = random_bytes(TOTP_ENROLLMENT_SECRET_LENGTH);
let totp = make_totp(secret.clone(), user.id.clone())?;
let png = totp
.get_qr_base64()
.map_err(anyhow::Error::msg)
.context("Failed to generate QR code png")?;
session
.insert(
SessionTotpEnrollment::KEY,
SessionTotpEnrollment { secret },
)
.await?;
Ok(BeginTotpEnrollmentResponse {
uri: totp.get_url(),
png,
})
}
}
impl Resolve<UserArgs> for ConfirmTotpEnrollment {
#[instrument(
"ConfirmTotpEnrollment",
skip_all,
fields(operator = user.id)
)]
async fn resolve(
self,
UserArgs { user, session }: &UserArgs,
) -> serror::Result<ConfirmTotpEnrollmentResponse> {
let session = session.as_ref().context(
"Method called in invalid context. This should not happen",
)?;
let SessionTotpEnrollment { secret } = session
.remove(SessionTotpEnrollment::KEY)
.await
.context("Totp enrollment was not initiated correctly")?
.context(
"Totp enrollment was not initiated correctly or timed out",
)?;
let encoded_secret = BASE32_NOPAD.encode(&secret);
let totp = make_totp(secret, None)?;
let valid = totp
.check_current(&self.code)
.context("Failed to check code validity")?;
if !valid {
return Err(anyhow!(
"The provided code was not valid. Please try BeginTotpEnrollment flow again."
).status_code(StatusCode::BAD_REQUEST));
}
let recovery_codes =
(0..10).map(|_| random_string(20)).collect::<Vec<_>>();
let hashed_recovery_codes = recovery_codes
.iter()
.map(hash_password)
.collect::<anyhow::Result<Vec<_>>>()
.context("Failed to generate valid recovery codes")?;
update_one_by_id(
&db_client().users,
&user.id,
doc! {
"$set": {
"totp.secret": encoded_secret,
"totp.confirmed_at": komodo_timestamp(),
"totp.recovery_codes": hashed_recovery_codes,
}
},
None,
)
.await
.context("Failed to update user totp fields on database")?;
Ok(ConfirmTotpEnrollmentResponse { recovery_codes })
}
}
impl Resolve<UserArgs> for UnenrollTotp {
#[instrument(
"UnenrollTotp",
skip_all,
fields(operator = user.id)
)]
async fn resolve(
self,
UserArgs { user, .. }: &UserArgs,
) -> serror::Result<UnenrollTotpResponse> {
update_one_by_id(
&db_client().users,
&user.id,
doc! {
"$set": {
"totp.secret": "",
"totp.confirmed_at": 0,
"totp.recovery_codes": [],
}
},
None,
)
.await
.context("Failed to clear user totp fields on database")?;
Ok(UnenrollTotpResponse {})
}
}
+1 -3
View File
@@ -23,7 +23,7 @@ mod alerter;
mod build;
mod builder;
mod deployment;
mod onboarding_key;
mod onboarding;
mod permissions;
mod procedure;
mod provider;
@@ -159,8 +159,6 @@ pub enum WriteRequest {
// ==== USER ====
CreateLocalUser(CreateLocalUser),
UpdateUserUsername(UpdateUserUsername),
UpdateUserPassword(UpdateUserPassword),
DeleteUser(DeleteUser),
// ==== SERVICE USER ====
@@ -45,7 +45,7 @@ impl Resolve<WriteArgs> for CreateOnboardingKey {
let private_key = if let Some(private_key) = self.private_key {
private_key
} else {
format!("O-{}", random_string(30))
format!("O_{}_O", random_string(28))
};
let public_key = EncodedKeyPair::from_private_key(&private_key)?
.public
+2
View File
@@ -53,12 +53,14 @@ impl Resolve<WriteArgs> for CreateServiceUser {
config,
totp: Default::default(),
passkey: Default::default(),
third_party_skip_2fa: Default::default(),
enabled: true,
admin: false,
super_admin: false,
create_server_permissions: false,
create_build_permissions: false,
last_update_view: 0,
linked_logins: Default::default(),
recents: Default::default(),
all: Default::default(),
updated_at: komodo_timestamp(),
+3 -92
View File
@@ -8,17 +8,13 @@ use database::{
};
use komodo_client::{
api::write::*,
entities::{
NoData,
user::{User, UserConfig},
},
entities::user::{User, UserConfig},
};
use reqwest::StatusCode;
use resolver_api::Resolve;
use serror::{AddStatusCode as _, AddStatusCodeError};
use crate::{
config::core_config,
helpers::validations::{validate_password, validate_username},
state::db_client,
};
@@ -82,8 +78,10 @@ impl Resolve<WriteArgs> for CreateLocalUser {
config: UserConfig::Local {
password: hashed_password,
},
linked_logins: Default::default(),
totp: Default::default(),
passkey: Default::default(),
third_party_skip_2fa: Default::default(),
};
user.id = db_client()
@@ -104,93 +102,6 @@ impl Resolve<WriteArgs> for CreateLocalUser {
//
impl Resolve<WriteArgs> for UpdateUserUsername {
#[instrument(
"UpdateUserUsername",
skip_all,
fields(
operator = user.id,
new_username = self.username,
)
)]
async fn resolve(
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<UpdateUserUsernameResponse> {
for locked_username in &core_config().lock_login_credentials_for {
if locked_username == "__ALL__"
|| *locked_username == user.username
{
return Err(
anyhow!("User not allowed to update their username.")
.into(),
);
}
}
validate_username(&self.username)?;
let db = db_client();
if db
.users
.find_one(doc! { "username": &self.username })
.await
.context("Failed to query for existing users")?
.is_some()
{
return Err(anyhow!("Username already taken.").into());
}
let id = ObjectId::from_str(&user.id)
.context("User id not valid ObjectId.")?;
db.users
.update_one(
doc! { "_id": id },
doc! { "$set": { "username": self.username } },
)
.await
.context("Failed to update user username on database.")?;
Ok(NoData {})
}
}
//
impl Resolve<WriteArgs> for UpdateUserPassword {
#[instrument(
"UpdateUserPassword",
skip_all,
fields(operator = user.id)
)]
async fn resolve(
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<UpdateUserPasswordResponse> {
for locked_username in &core_config().lock_login_credentials_for {
if locked_username == "__ALL__"
|| *locked_username == user.username
{
return Err(
anyhow!("User not allowed to update their password.")
.into(),
);
}
}
validate_password(&self.password)
.status_code(StatusCode::BAD_REQUEST)?;
db_client().set_user_password(user, &self.password).await?;
Ok(NoData {})
}
}
//
impl Resolve<WriteArgs> for DeleteUser {
#[instrument(
"DeleteUser",
+5 -22
View File
@@ -7,15 +7,15 @@ use komodo_client::entities::{
};
use reqwest::StatusCode;
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use tokio::sync::Mutex;
use crate::{auth::STATE_PREFIX_LENGTH, config::core_config};
pub fn github_oauth_client() -> &'static Option<GithubOauthClient> {
pub fn github_oauth_client() -> Option<&'static GithubOauthClient> {
static GITHUB_OAUTH_CLIENT: OnceLock<Option<GithubOauthClient>> =
OnceLock::new();
GITHUB_OAUTH_CLIENT
.get_or_init(|| GithubOauthClient::new(core_config()))
.as_ref()
}
pub struct GithubOauthClient {
@@ -24,7 +24,6 @@ pub struct GithubOauthClient {
client_secret: String,
redirect_uri: String,
scopes: String,
states: Mutex<Vec<String>>,
user_agent: String,
}
@@ -69,15 +68,14 @@ impl GithubOauthClient {
redirect_uri: format!("{host}/auth/github/callback"),
user_agent: Default::default(),
scopes: Default::default(),
states: Default::default(),
}
.into()
}
pub async fn get_login_redirect_url(
pub async fn get_state_and_login_redirect_url(
&self,
redirect: Option<String>,
) -> String {
) -> (String, String) {
let state_prefix = random_string(STATE_PREFIX_LENGTH);
let state = match redirect {
Some(redirect) => format!("{state_prefix}{redirect}"),
@@ -87,22 +85,7 @@ impl GithubOauthClient {
"https://github.com/login/oauth/authorize?state={state}&client_id={}&redirect_uri={}&scope={}",
self.client_id, self.redirect_uri, self.scopes
);
let mut states = self.states.lock().await;
states.push(state);
redirect_url
}
pub async fn check_state(&self, state: &str) -> bool {
let mut contained = false;
self.states.lock().await.retain(|s| {
if s.as_str() == state {
contained = true;
false
} else {
true
}
});
contained
(state, redirect_url)
}
pub async fn get_access_token(
+182 -39
View File
@@ -8,8 +8,10 @@ use axum::{
response::Redirect,
routing::get,
};
use database::mongo_indexed::Document;
use database::mungos::mongodb::bson::doc;
use database::{
mongo_indexed::Document, mungos::by_id::update_one_by_id,
};
use futures_util::TryFutureExt;
use komodo_client::{
api::auth::UserIdOrTwoFactor,
@@ -20,17 +22,18 @@ use komodo_client::{
};
use rate_limit::WithFailureRateLimit;
use reqwest::StatusCode;
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use serror::{AddStatusCode, AddStatusCodeError as _};
use tower_sessions::Session;
use crate::{
api::{
SESSION_KEY_PASSKEY_LOGIN, SESSION_KEY_TOTP_LOGIN,
SESSION_KEY_USER_ID,
api::user::SessionThirdPartyLinkInfo,
auth::{
SessionPasskeyLogin, SessionTotpLogin, SessionUserId,
format_redirect, github::client::GithubOauthClient,
},
auth::format_redirect,
config::core_config,
helpers::query::{find_github_user, get_user},
state::{auth_rate_limiter, db_client, webauthn},
};
@@ -42,18 +45,8 @@ pub mod client;
pub fn router() -> Router {
Router::new()
.route(
"/login",
get(|Query(query): Query<RedirectQuery>| async {
let uri = github_oauth_client()
.as_ref()
.context("Github Oauth not configured")
.status_code(StatusCode::UNAUTHORIZED)?
.get_login_redirect_url(query.redirect)
.await;
serror::Result::Ok(Redirect::to(&uri))
}),
)
.route("/login", get(login))
.route("/link", get(link))
.route(
"/callback",
get(
@@ -74,6 +67,67 @@ pub fn router() -> Router {
)
}
#[derive(Serialize, Deserialize)]
struct SessionGithubVerificationInfo {
state: String,
}
impl SessionGithubVerificationInfo {
const KEY: &str = "github-verification-info";
}
async fn login(
session: Session,
Query(query): Query<RedirectQuery>,
) -> serror::Result<Redirect> {
let (state, uri) = github_oauth_client()
.as_ref()
.context("Github Oauth not configured")
.status_code(StatusCode::UNAUTHORIZED)?
.get_state_and_login_redirect_url(query.redirect)
.await;
session
.insert(
SessionGithubVerificationInfo::KEY,
SessionGithubVerificationInfo { state },
)
.await
.context("Failed to insert github oauth session state")?;
Ok(Redirect::to(&uri))
}
#[derive(Serialize, Deserialize)]
struct SessionGithubLinkInfo {
user_id: String,
state: String,
}
impl SessionGithubLinkInfo {
const KEY: &str = "github-link-info";
}
async fn link(session: Session) -> serror::Result<Redirect> {
let SessionThirdPartyLinkInfo { user_id } = session
.remove(SessionThirdPartyLinkInfo::KEY)
.await
.context("Invalid session third party link info.")?
.context("Missing session third party link info")?;
let (state, uri) = github_oauth_client()
.as_ref()
.context("Github Oauth not configured")
.status_code(StatusCode::UNAUTHORIZED)?
.get_state_and_login_redirect_url(None)
.await;
session
.insert(
SessionGithubLinkInfo::KEY,
SessionGithubLinkInfo { user_id, state },
)
.await
.context("Failed to insert session link info")?;
Ok(Redirect::to(&uri))
}
#[derive(Debug, Deserialize)]
struct CallbackQuery {
state: String,
@@ -84,25 +138,58 @@ async fn callback(
Query(query): Query<CallbackQuery>,
session: Session,
) -> anyhow::Result<Redirect> {
let client = github_oauth_client().as_ref().unwrap();
if !client.check_state(&query.state).await {
return Err(anyhow!("state mismatch"));
let client =
github_oauth_client().context("Missing github oauth client")?;
// Check first if this is a link callback
// and use the linking handler if so.
if let Ok(Some(info)) =
session.remove(SessionGithubLinkInfo::KEY).await
{
return link_github_callback(client, info, query).await;
}
let SessionGithubVerificationInfo { state } = session
.remove(SessionGithubVerificationInfo::KEY)
.await
.context("Invalid github oauth session state")?
.context("Missing github oauth session state")?;
if query.state != state {
return Err(anyhow!("State mismatch"));
}
let token = client.get_access_token(&query.code).await?;
let github_user =
client.get_github_user(&token.access_token).await?;
let github_id = github_user.id.to_string();
let db_client = db_client();
let user = db_client
.users
.find_one(doc! { "config.data.github_id": &github_id })
.await
.context("failed at find user query from database")?;
let user = find_github_user(&github_id).await?;
let user_id_or_two_factor = match user {
Some(user) => {
match (user.passkey.passkey, user.totp.enrolled()) {
match (
user.third_party_skip_2fa,
user.passkey.passkey,
user.totp.enrolled(),
) {
// Skip / No 2FA
(true, _, _) | (false, None, false) => {
session
.insert(
SessionUserId::KEY,
SessionUserId(user.id.clone()),
)
.await
.context("Failed to store user id for client session")?;
UserIdOrTwoFactor::UserId(user.id)
}
// WebAuthn Passkey 2FA
(Some(passkey), _) => {
(false, Some(passkey), _) => {
let webauthn = webauthn().context(
"No webauthn provider available, invalid KOMODO_HOST config",
)?;
@@ -111,24 +198,28 @@ async fn callback(
.context("Failed to start passkey authentication flow")?;
session
.insert(
SESSION_KEY_PASSKEY_LOGIN,
(user.id, server_state),
SessionPasskeyLogin::KEY,
SessionPasskeyLogin {
user_id: user.id,
state: server_state,
},
)
.await?;
UserIdOrTwoFactor::Passkey(response)
}
// TOTP 2FA
(None, true) => {
(false, None, true) => {
session
.insert(SESSION_KEY_TOTP_LOGIN, user.id)
.insert(
SessionTotpLogin::KEY,
SessionTotpLogin { user_id: user.id },
)
.await
.context(
"Failed to store totp login state in for user session",
)?;
UserIdOrTwoFactor::Totp {}
}
// No 2FA
(None, false) => UserIdOrTwoFactor::UserId(user.id),
}
}
None => {
@@ -169,8 +260,10 @@ async fn callback(
github_id,
avatar: github_user.avatar_url,
},
linked_logins: Default::default(),
totp: Default::default(),
passkey: Default::default(),
third_party_skip_2fa: Default::default(),
};
let user_id = db_client
.users
@@ -186,11 +279,7 @@ async fn callback(
};
let redirect = Some(&query.state[STATE_PREFIX_LENGTH..]);
match user_id_or_two_factor {
UserIdOrTwoFactor::UserId(user_id) => {
session
.insert(SESSION_KEY_USER_ID, user_id)
.await
.context("Failed to store user id for client session")?;
UserIdOrTwoFactor::UserId(_) => {
Ok(format_redirect(redirect, "redeem_ready=true"))
}
UserIdOrTwoFactor::Totp {} => {
@@ -204,3 +293,57 @@ async fn callback(
}
}
}
/// This intercepts during the normal oauth callback if
/// 'github-link-info' is found on session.
async fn link_github_callback(
client: &GithubOauthClient,
SessionGithubLinkInfo { user_id, state }: SessionGithubLinkInfo,
query: CallbackQuery,
) -> anyhow::Result<Redirect> {
if query.state != state {
return Err(anyhow!("State mismatch"));
}
let token = client.get_access_token(&query.code).await?;
let github_user =
client.get_github_user(&token.access_token).await?;
let github_id = github_user.id.to_string();
if let Some(existing_user) = find_github_user(&github_id).await? {
if existing_user.id == user_id {
// Link is already complete, this is a no-op
return Ok(Redirect::to(&format!(
"{}/settings",
core_config().host,
)));
} else {
return Err(anyhow!("Account already linked to another user."));
}
}
let user = get_user(&user_id).await?;
if let UserConfig::Github { .. } = &user.config {
return Err(anyhow!(
"User is primary Github user, cannot link another Github login."
));
}
let update = doc! {
"$set": {
"linked_logins.Github.type": "Github",
"linked_logins.Github.data.github_id": &github_id,
"linked_logins.Github.data.avatar": &github_user.avatar_url,
}
};
update_one_by_id(&db_client().users, &user_id, update, None)
.await
.context(
"Failed to link Github login to existing user on database",
)?;
Ok(Redirect::to(&format!("{}/settings", core_config().host,)))
}
+5 -22
View File
@@ -8,15 +8,15 @@ use komodo_client::entities::{
};
use reqwest::StatusCode;
use serde::{Deserialize, de::DeserializeOwned};
use tokio::sync::Mutex;
use crate::{auth::STATE_PREFIX_LENGTH, config::core_config};
pub fn google_oauth_client() -> &'static Option<GoogleOauthClient> {
pub fn google_oauth_client() -> Option<&'static GoogleOauthClient> {
static GOOGLE_OAUTH_CLIENT: OnceLock<Option<GoogleOauthClient>> =
OnceLock::new();
GOOGLE_OAUTH_CLIENT
.get_or_init(|| GoogleOauthClient::new(core_config()))
.as_ref()
}
pub struct GoogleOauthClient {
@@ -25,7 +25,6 @@ pub struct GoogleOauthClient {
client_secret: String,
redirect_uri: String,
scopes: String,
states: Mutex<Vec<String>>,
user_agent: String,
}
@@ -77,16 +76,15 @@ impl GoogleOauthClient {
client_secret: secret.clone(),
redirect_uri: format!("{host}/auth/google/callback"),
user_agent: String::from("komodo"),
states: Default::default(),
scopes,
}
.into()
}
pub async fn get_login_redirect_url(
pub async fn get_state_and_login_redirect_url(
&self,
redirect: Option<String>,
) -> String {
) -> (String, String) {
let state_prefix = random_string(STATE_PREFIX_LENGTH);
let state = match redirect {
Some(redirect) => format!("{state_prefix}{redirect}"),
@@ -96,22 +94,7 @@ impl GoogleOauthClient {
"https://accounts.google.com/o/oauth2/v2/auth?response_type=code&state={state}&client_id={}&redirect_uri={}&scope={}",
self.client_id, self.redirect_uri, self.scopes
);
let mut states = self.states.lock().await;
states.push(state);
redirect_url
}
pub async fn check_state(&self, state: &str) -> bool {
let mut contained = false;
self.states.lock().await.retain(|s| {
if s.as_str() == state {
contained = true;
false
} else {
true
}
});
contained
(state, redirect_url)
}
pub async fn get_access_token(
+194 -50
View File
@@ -9,8 +9,10 @@ use axum::{
response::Redirect,
routing::get,
};
use database::mongo_indexed::Document;
use database::mungos::mongodb::bson::doc;
use database::{
mongo_indexed::Document, mungos::by_id::update_one_by_id,
};
use futures_util::TryFutureExt;
use komodo_client::{
api::auth::UserIdOrTwoFactor,
@@ -21,14 +23,18 @@ use komodo_client::{
};
use rate_limit::WithFailureRateLimit;
use reqwest::StatusCode;
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use serror::{AddStatusCode, AddStatusCodeError as _};
use tower_sessions::Session;
use crate::{
api::{SESSION_KEY_PASSKEY_LOGIN, SESSION_KEY_TOTP_LOGIN, SESSION_KEY_USER_ID},
auth::format_redirect,
api::user::SessionThirdPartyLinkInfo,
auth::{
SessionPasskeyLogin, SessionTotpLogin, SessionUserId,
format_redirect, google::client::GoogleOauthClient,
},
config::core_config,
helpers::query::{find_google_user, get_user},
state::{auth_rate_limiter, db_client, webauthn},
};
@@ -40,26 +46,16 @@ pub mod client;
pub fn router() -> Router {
Router::new()
.route(
"/login",
get(|Query(query): Query<RedirectQuery>| async move {
let uri = google_oauth_client()
.as_ref()
.context("Google Oauth not configured")
.status_code(StatusCode::UNAUTHORIZED)?
.get_login_redirect_url(query.redirect)
.await;
serror::Result::Ok(Redirect::to(&uri))
}),
)
.route("/login", get(login))
.route("/link", get(link))
.route(
"/callback",
get(
|query,
session: Session,
session,
headers: HeaderMap,
ConnectInfo(info): ConnectInfo<SocketAddr>| async move {
callback(query, session)
callback(session, query)
.map_err(|e| e.status_code(StatusCode::UNAUTHORIZED))
.with_failure_rate_limit_using_headers(
auth_rate_limiter(),
@@ -72,6 +68,67 @@ pub fn router() -> Router {
)
}
#[derive(Serialize, Deserialize)]
struct SessionGoogleVerificationInfo {
state: String,
}
impl SessionGoogleVerificationInfo {
const KEY: &str = "google-verification-info";
}
async fn login(
session: Session,
Query(query): Query<RedirectQuery>,
) -> serror::Result<Redirect> {
let (state, uri) = google_oauth_client()
.as_ref()
.context("Google Oauth not configured")
.status_code(StatusCode::UNAUTHORIZED)?
.get_state_and_login_redirect_url(query.redirect)
.await;
session
.insert(
SessionGoogleVerificationInfo::KEY,
SessionGoogleVerificationInfo { state },
)
.await
.context("Failed to insert google oauth session state")?;
Ok(Redirect::to(&uri))
}
#[derive(Serialize, Deserialize)]
struct SessionGoogleLinkInfo {
user_id: String,
state: String,
}
impl SessionGoogleLinkInfo {
const KEY: &str = "google-link-info";
}
async fn link(session: Session) -> serror::Result<Redirect> {
let SessionThirdPartyLinkInfo { user_id } = session
.remove(SessionThirdPartyLinkInfo::KEY)
.await
.context("Invalid session third party link info.")?
.context("Missing session third party link info")?;
let (state, uri) = google_oauth_client()
.as_ref()
.context("Google Oauth not configured")
.status_code(StatusCode::UNAUTHORIZED)?
.get_state_and_login_redirect_url(None)
.await;
session
.insert(
SessionGoogleLinkInfo::KEY,
SessionGoogleLinkInfo { user_id, state },
)
.await
.context("Failed to insert session link info")?;
Ok(Redirect::to(&uri))
}
#[derive(Debug, Deserialize)]
struct CallbackQuery {
state: Option<String>,
@@ -80,38 +137,68 @@ struct CallbackQuery {
}
async fn callback(
Query(query): Query<CallbackQuery>,
session: Session,
Query(query): Query<CallbackQuery>,
) -> anyhow::Result<Redirect> {
// Safe: the method is only called after the client is_some
let client = google_oauth_client().as_ref().unwrap();
let client =
google_oauth_client().context("Missing google oauth client")?;
if let Some(error) = query.error {
return Err(anyhow!("auth error from google: {error}"));
return Err(anyhow!("Auth error from google: {error}"));
}
let state = query
let client_state = query
.state
.context("callback query does not contain state")?;
if !client.check_state(&state).await {
return Err(anyhow!("state mismatch"));
.context("Callback query does not contain state")?;
let code =
query.code.context("callback query does not contain code")?;
// Check first if this is a link callback
// and use the linking handler if so.
if let Ok(Some(info)) =
session.remove(SessionGoogleLinkInfo::KEY).await
{
return link_google_callback(client, info, client_state, code)
.await;
}
let token = client
.get_access_token(
&query.code.context("callback query does not contain code")?,
)
.await?;
let SessionGoogleVerificationInfo { state } = session
.get(SessionGoogleVerificationInfo::KEY)
.await
.context("Invalid google oauth session state")?
.context("Missing google oauth session state")?;
if client_state != state {
return Err(anyhow!("State mismatch"));
}
let token = client.get_access_token(&code).await?;
let google_user = client.get_google_user(&token.id_token)?;
let google_id = google_user.id.to_string();
let db_client = db_client();
let user = db_client
.users
.find_one(doc! { "config.data.google_id": &google_id })
.await
.context("failed at find user query from mongo")?;
let user = find_google_user(&google_id).await?;
let user_id_or_two_factor = match user {
Some(user) => {
match (user.passkey.passkey, user.totp.enrolled()) {
match (
user.third_party_skip_2fa,
user.passkey.passkey,
user.totp.enrolled(),
) {
// Skip / No 2FA
(true, _, _) | (false, None, false) => {
session
.insert(
SessionUserId::KEY,
SessionUserId(user.id.clone()),
)
.await
.context("Failed to store user id for client session")?;
UserIdOrTwoFactor::UserId(user.id)
}
// WebAuthn Passkey 2FA
(Some(passkey), _) => {
(false, Some(passkey), _) => {
let webauthn = webauthn().context(
"No webauthn provider available, invalid KOMODO_HOST config",
)?;
@@ -120,27 +207,32 @@ async fn callback(
.context("Failed to start passkey authentication flow")?;
session
.insert(
SESSION_KEY_PASSKEY_LOGIN,
(user.id, server_state),
SessionPasskeyLogin::KEY,
SessionPasskeyLogin {
user_id: user.id,
state: server_state,
},
)
.await?;
UserIdOrTwoFactor::Passkey(response)
}
// TOTP 2FA
(None, true) => {
(false, None, true) => {
session
.insert(SESSION_KEY_TOTP_LOGIN, user.id)
.insert(
SessionTotpLogin::KEY,
SessionTotpLogin { user_id: user.id },
)
.await
.context(
"Failed to store totp login state in for user session",
)?;
UserIdOrTwoFactor::Totp {}
}
// No 2FA
(None, false) => UserIdOrTwoFactor::UserId(user.id),
}
}
None => {
let db_client = db_client();
let ts = unix_timestamp_ms() as i64;
let no_users_exist =
db_client.users.find_one(Document::new()).await?.is_none();
@@ -183,8 +275,10 @@ async fn callback(
google_id,
avatar: google_user.picture,
},
linked_logins: Default::default(),
totp: Default::default(),
passkey: Default::default(),
third_party_skip_2fa: Default::default(),
};
let user_id = db_client
.users
@@ -200,11 +294,7 @@ async fn callback(
};
let redirect = Some(&state[STATE_PREFIX_LENGTH..]);
match user_id_or_two_factor {
UserIdOrTwoFactor::UserId(user_id) => {
session
.insert(SESSION_KEY_USER_ID, user_id)
.await
.context("Failed to store user id for client session")?;
UserIdOrTwoFactor::UserId(_) => {
Ok(format_redirect(redirect, "redeem_ready=true"))
}
UserIdOrTwoFactor::Totp {} => {
@@ -218,3 +308,57 @@ async fn callback(
}
}
}
/// This intercepts during the normal oauth callback if
/// 'google-link-info' is found on session.
async fn link_google_callback(
client: &GoogleOauthClient,
SessionGoogleLinkInfo { user_id, state }: SessionGoogleLinkInfo,
client_state: String,
code: String,
) -> anyhow::Result<Redirect> {
if client_state != state {
return Err(anyhow!("State mismatch"));
}
let token = client.get_access_token(&code).await?;
let google_user = client.get_google_user(&token.id_token)?;
let google_id = google_user.id.to_string();
if let Some(existing_user) = find_google_user(&google_id).await? {
if existing_user.id == user_id {
// Link is already complete, this is a no-op
return Ok(Redirect::to(&format!(
"{}/settings",
core_config().host,
)));
} else {
return Err(anyhow!("Account already linked to another user."));
}
}
let user = get_user(&user_id).await?;
if let UserConfig::Google { .. } = &user.config {
return Err(anyhow!(
"User is primary Google user, cannot link another Google login."
));
}
let update = doc! {
"$set": {
"linked_logins.Google.type": "Google",
"linked_logins.Google.data.google_id": &google_id,
"linked_logins.Google.data.avatar": &google_user.picture,
}
};
update_one_by_id(&db_client().users, &user_id, update, None)
.await
.context(
"Failed to link Google login to existing user on database",
)?;
Ok(Redirect::to(&format!("{}/settings", core_config().host,)))
}
+33 -17
View File
@@ -11,7 +11,7 @@ use komodo_client::{
LoginLocalUser, LoginLocalUserResponse, SignUpLocalUser,
SignUpLocalUserResponse,
},
entities::user::{User, UserConfig},
entities::user::{User, UserConfig, UserConfigVariant},
};
use rate_limit::{RateLimiter, WithFailureRateLimit};
use reqwest::StatusCode;
@@ -20,9 +20,8 @@ use serror::{AddStatusCode as _, AddStatusCodeError};
use tower_sessions::Session;
use crate::{
api::{
SESSION_KEY_PASSKEY_LOGIN, SESSION_KEY_TOTP_LOGIN, auth::AuthArgs,
},
api::auth::AuthArgs,
auth::{SessionPasskeyLogin, SessionTotpLogin},
config::core_config,
helpers::validations::{validate_password, validate_username},
state::{auth_rate_limiter, db_client, jwt_client, webauthn},
@@ -109,8 +108,10 @@ async fn sign_up_local_user(
config: UserConfig::Local {
password: hashed_password,
},
linked_logins: Default::default(),
totp: Default::default(),
passkey: Default::default(),
third_party_skip_2fa: Default::default(),
};
let user_id = db_client()
@@ -192,17 +193,21 @@ async fn login_local_user(
.context("Invalid login credentials")
.status_code(StatusCode::UNAUTHORIZED)?;
let UserConfig::Local {
password: user_pw_hash,
} = user.config
else {
return Err(
anyhow!("Invalid login credentials")
.status_code(StatusCode::UNAUTHORIZED),
);
};
let user_pw_hash =
if let UserConfig::Local { password } = &user.config {
password
} else if let Some(UserConfig::Local { password }) =
user.linked_logins.get(UserConfigVariant::Local)
{
password
} else {
return Err(
anyhow!("Invalid login credentials")
.status_code(StatusCode::UNAUTHORIZED),
);
};
let verified = bcrypt::verify(req.password, &user_pw_hash)
let verified = bcrypt::verify(req.password, user_pw_hash)
.context("Invalid login credentials")
.status_code(StatusCode::UNAUTHORIZED)?;
@@ -223,13 +228,24 @@ async fn login_local_user(
.start_passkey_authentication(&[passkey])
.context("Failed to start passkey authentication flow")?;
session
.insert(SESSION_KEY_PASSKEY_LOGIN, (user.id, server_state))
.insert(
SessionPasskeyLogin::KEY,
SessionPasskeyLogin {
user_id: user.id,
state: server_state,
},
)
.await?;
Ok(LoginLocalUserResponse::Passkey(response))
}
// TOTP 2FA
(None, true) => {
session.insert(SESSION_KEY_TOTP_LOGIN, user.id).await?;
session
.insert(
SessionTotpLogin::KEY,
SessionTotpLogin { user_id: user.id },
)
.await?;
Ok(LoginLocalUserResponse::Totp {})
}
// No 2FA, can return JWT immediately
@@ -238,7 +254,7 @@ async fn login_local_user(
.encode(user.id)
// This is in internal error (500), not auth error
.context("Failed to generate JWT for user")
.map(|jwt| LoginLocalUserResponse::Jwt(jwt))
.map(LoginLocalUserResponse::Jwt)
.map_err(Into::into)
}
}
+27 -2
View File
@@ -13,8 +13,9 @@ use futures_util::TryFutureExt;
use komodo_client::entities::{komodo_timestamp, user::User};
use rate_limit::WithFailureRateLimit;
use reqwest::StatusCode;
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use serror::AddStatusCodeError as _;
use webauthn_rs::prelude::PasskeyAuthentication;
use crate::{
config::core_config,
@@ -44,6 +45,30 @@ struct RedirectQuery {
redirect: Option<String>,
}
#[derive(Serialize, Deserialize)]
pub struct SessionUserId(pub String);
impl SessionUserId {
pub const KEY: &str = "user-id";
}
#[derive(Serialize, Deserialize)]
pub struct SessionTotpLogin {
pub user_id: String,
}
impl SessionTotpLogin {
pub const KEY: &str = "totp-login";
}
#[derive(Serialize, Deserialize)]
pub struct SessionPasskeyLogin {
pub user_id: String,
/// This must stay server side only
pub state: PasskeyAuthentication,
}
impl SessionPasskeyLogin {
pub const KEY: &str = "passkey-login";
}
pub async fn auth_request(
headers: HeaderMap,
mut req: Request,
@@ -62,7 +87,7 @@ pub async fn auth_request(
)
.await?;
// Sanitize the user for safety before
// attaching to the request handlers.
// attaching to the request handlers.
user.sanitize();
req.extensions_mut().insert(user);
Ok(next.run(req).await)
+131 -7
View File
@@ -1,16 +1,33 @@
use std::{sync::OnceLock, time::Duration};
use anyhow::Context;
use anyhow::{Context, anyhow};
use arc_swap::ArcSwapOption;
use openidconnect::{
Client, ClientId, ClientSecret, EmptyAdditionalClaims,
EndpointMaybeSet, EndpointNotSet, EndpointSet, IssuerUrl,
RedirectUrl, StandardErrorResponse, core::*,
AccessToken, AccessTokenHash, AuthorizationCode, Client, ClientId,
ClientSecret, CsrfToken, EmptyAdditionalClaims, EndpointMaybeSet,
EndpointNotSet, EndpointSet, IssuerUrl, Nonce, OAuth2TokenResponse,
PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, Scope,
StandardErrorResponse, SubjectIdentifier, TokenResponse as _,
UserInfoClaims, core::*,
};
use crate::config::core_config;
type OidcClient = Client<
static APP_USER_AGENT: &str =
concat!("Komodo/", env!("CARGO_PKG_VERSION"),);
fn oidc_reqwest_client() -> &'static reqwest::Client {
static REQWEST: OnceLock<reqwest::Client> = OnceLock::new();
REQWEST.get_or_init(|| {
reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.user_agent(APP_USER_AGENT)
.build()
.expect("Invalid OIDC reqwest client")
})
}
pub type InnerOidcClient = Client<
EmptyAdditionalClaims,
CoreAuthDisplay,
CoreGenderClaim,
@@ -30,6 +47,8 @@ type OidcClient = Client<
EndpointMaybeSet,
>;
pub struct OidcClient(InnerOidcClient);
pub fn oidc_client() -> &'static ArcSwapOption<OidcClient> {
static OIDC_CLIENT: OnceLock<ArcSwapOption<OidcClient>> =
OnceLock::new();
@@ -66,7 +85,7 @@ async fn reset_oidc_client() -> anyhow::Result<()> {
// Use OpenID Connect Discovery to fetch the provider metadata.
let provider_metadata = CoreProviderMetadata::discover_async(
IssuerUrl::new(config.oidc_provider.clone())?,
super::reqwest_client(),
oidc_reqwest_client(),
)
.await
.context("Failed to get OIDC /.well-known/openid-configuration")?;
@@ -87,7 +106,112 @@ async fn reset_oidc_client() -> anyhow::Result<()> {
core_config().host
))?);
oidc_client().store(Some(client.into()));
oidc_client().store(Some(OidcClient(client).into()));
Ok(())
}
impl OidcClient {
pub fn authorize_url(
&self,
pkce_challenge: PkceCodeChallenge,
) -> (url::Url, CsrfToken, Nonce) {
self
.0
.authorize_url(
CoreAuthenticationFlow::AuthorizationCode,
CsrfToken::new_random,
Nonce::new_random,
)
.set_pkce_challenge(pkce_challenge)
.add_scope(Scope::new("openid".to_string()))
.add_scope(Scope::new("profile".to_string()))
.add_scope(Scope::new("email".to_string()))
.url()
}
/// Applied security validations and extracts the
/// oidc user id.
pub async fn validate_extract_subject_and_token(
&self,
(client, server): (CsrfToken, String),
code: String,
pkce_verifier: PkceCodeVerifier,
nonce: Nonce,
) -> anyhow::Result<(SubjectIdentifier, AccessToken)> {
// Validate CSRF tokens match
if client.secret() != &server {
return Err(anyhow!("CSRF token invalid"));
}
let reqwest_client = oidc_reqwest_client();
let token_response = self
.0
.exchange_code(AuthorizationCode::new(code))
.context("Failed to get Oauth token at exchange code")?
.set_pkce_verifier(pkce_verifier)
.request_async(reqwest_client)
.await
.context("Failed to get Oauth token")?;
// Extract the ID token claims after verifying its authenticity and nonce.
let id_token = token_response
.id_token()
.context("OIDC Server did not return an ID token")?;
// Some providers attach additional audiences, they must be added here
// so token verification succeeds.
let verifier = self.0.id_token_verifier();
let additional_audiences =
&core_config().oidc_additional_audiences;
let verifier = if additional_audiences.is_empty() {
verifier
} else {
verifier.set_other_audience_verifier_fn(|aud| {
additional_audiences.contains(aud)
})
};
let claims = id_token
.claims(&verifier, &nonce)
.context("Failed to verify token claims. This issue may be temporary (60 seconds max).")?;
// Verify the access token hash to ensure that the access token hasn't been substituted for
// another user's.
if let Some(expected_access_token_hash) =
claims.access_token_hash()
{
let actual_access_token_hash = AccessTokenHash::from_token(
&token_response.access_token().clone(),
id_token.signing_alg()?,
id_token.signing_key(&verifier)?,
)?;
if actual_access_token_hash != *expected_access_token_hash {
return Err(anyhow!("Invalid access token"));
}
}
Ok((
claims.subject().clone(),
token_response.access_token().clone(),
))
}
pub async fn fetch_user_info(
&self,
access_token: AccessToken,
subject: SubjectIdentifier,
) -> anyhow::Result<
UserInfoClaims<EmptyAdditionalClaims, CoreGenderClaim>,
> {
self
.0
.user_info(access_token, Some(subject))
.context("Invalid user info request")?
.request_async::<EmptyAdditionalClaims, _, CoreGenderClaim>(
oidc_reqwest_client(),
)
.await
.context("Failed to fetch OIDC user info")
}
}
+232 -157
View File
@@ -1,4 +1,4 @@
use std::{net::SocketAddr, sync::OnceLock};
use std::net::SocketAddr;
use anyhow::{Context, anyhow};
use axum::{
@@ -9,8 +9,10 @@ use axum::{
routing::get,
};
use client::oidc_client;
use dashmap::DashMap;
use database::mungos::mongodb::bson::{Document, doc};
use database::mungos::{
by_id::update_one_by_id,
mongodb::bson::{Document, doc},
};
use futures_util::TryFutureExt;
use komodo_client::{
api::auth::UserIdOrTwoFactor,
@@ -20,24 +22,22 @@ use komodo_client::{
},
};
use openidconnect::{
AccessTokenHash, AuthorizationCode, CsrfToken,
EmptyAdditionalClaims, Nonce, OAuth2TokenResponse,
PkceCodeChallenge, PkceCodeVerifier, Scope, TokenResponse,
core::{CoreAuthenticationFlow, CoreGenderClaim},
CsrfToken, Nonce, PkceCodeChallenge, PkceCodeVerifier,
};
use rate_limit::WithFailureRateLimit;
use reqwest::StatusCode;
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use serror::{AddStatusCode as _, AddStatusCodeError};
use tower_sessions::Session;
use crate::{
api::{
SESSION_KEY_PASSKEY_LOGIN, SESSION_KEY_TOTP_LOGIN,
SESSION_KEY_USER_ID,
api::user::SessionThirdPartyLinkInfo,
auth::{
SessionPasskeyLogin, SessionTotpLogin, SessionUserId,
format_redirect, oidc::client::OidcClient,
},
auth::format_redirect,
config::core_config,
helpers::query::{find_oidc_user, get_user},
state::{auth_rate_limiter, db_client, webauthn},
};
@@ -45,50 +45,30 @@ use super::RedirectQuery;
pub mod client;
static APP_USER_AGENT: &str =
concat!("Komodo/", env!("CARGO_PKG_VERSION"),);
fn reqwest_client() -> &'static reqwest::Client {
static REQWEST: OnceLock<reqwest::Client> = OnceLock::new();
REQWEST.get_or_init(|| {
reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.user_agent(APP_USER_AGENT)
.build()
.expect("Invalid OIDC reqwest client")
})
}
/// CSRF tokens can only be used once from the callback,
/// and must be used within this timeframe
const CSRF_VALID_FOR_MS: i64 = 120_000; // 2 minutes for user to log in.
type RedirectUrl = Option<String>;
/// Maps the csrf secrets to other information added in the "login" method (before auth provider redirect).
/// This information is retrieved in the "callback" method (after auth provider redirect).
type VerifierMap =
DashMap<String, (PkceCodeVerifier, Nonce, RedirectUrl, i64)>;
fn verifier_tokens() -> &'static VerifierMap {
static VERIFIERS: OnceLock<VerifierMap> = OnceLock::new();
VERIFIERS.get_or_init(Default::default)
}
pub fn router() -> Router {
Router::new()
.route(
"/login",
get(|query| async {
login(query).await.status_code(StatusCode::UNAUTHORIZED)
get(|session, query| async {
login(session, query)
.await
.status_code(StatusCode::UNAUTHORIZED)
}),
)
.route(
"/link",
get(|session| async {
link(session).await.status_code(StatusCode::UNAUTHORIZED)
}),
)
.route(
"/callback",
get(
|query,
session: Session,
session,
headers: HeaderMap,
ConnectInfo(info): ConnectInfo<SocketAddr>| async move {
callback(query, session)
callback(session, query)
.map_err(|e| e.status_code(StatusCode::UNAUTHORIZED))
.with_failure_rate_limit_using_headers(
auth_rate_limiter(),
@@ -101,7 +81,22 @@ pub fn router() -> Router {
)
}
type RedirectUrl = Option<String>;
#[derive(Serialize, Deserialize)]
struct SessionOidcVerificationInfo {
csrf_token: String,
pkce_verifier: PkceCodeVerifier,
nonce: Nonce,
redirect: RedirectUrl,
}
impl SessionOidcVerificationInfo {
const KEY: &str = "oidc-verification-info";
}
async fn login(
session: Session,
Query(RedirectQuery { redirect }): Query<RedirectQuery>,
) -> anyhow::Result<Redirect> {
let client = oidc_client().load();
@@ -112,32 +107,76 @@ async fn login(
PkceCodeChallenge::new_random_sha256();
// Generate the authorization URL.
let (auth_url, csrf_token, nonce) = client
.authorize_url(
CoreAuthenticationFlow::AuthorizationCode,
CsrfToken::new_random,
Nonce::new_random,
)
.set_pkce_challenge(pkce_challenge)
.add_scope(Scope::new("openid".to_string()))
.add_scope(Scope::new("profile".to_string()))
.add_scope(Scope::new("email".to_string()))
.url();
let (auth_url, csrf_token, nonce) =
client.authorize_url(pkce_challenge);
// Data inserted here will be matched on callback side for csrf protection.
verifier_tokens().insert(
csrf_token.secret().clone(),
(
pkce_verifier,
nonce,
redirect,
komodo_timestamp() + CSRF_VALID_FOR_MS,
),
);
session
.insert(
SessionOidcVerificationInfo::KEY,
SessionOidcVerificationInfo {
csrf_token: csrf_token.secret().clone(),
pkce_verifier,
nonce,
redirect,
},
)
.await
.context("Failed to insert session verification info")?;
auth_redirect(auth_url.as_str())
}
#[derive(Serialize, Deserialize)]
struct SessionOidcLinkInfo {
user_id: String,
csrf_token: String,
pkce_verifier: PkceCodeVerifier,
nonce: Nonce,
}
impl SessionOidcLinkInfo {
const KEY: &str = "oidc-link-info";
}
async fn link(session: Session) -> anyhow::Result<Redirect> {
let SessionThirdPartyLinkInfo { user_id } = session
.remove(SessionThirdPartyLinkInfo::KEY)
.await
.context("Invalid session third party link info.")?
.context("Missing session third party link info")?;
let client = oidc_client().load();
let client =
client.as_ref().context("OIDC Client not configured")?;
let (pkce_challenge, pkce_verifier) =
PkceCodeChallenge::new_random_sha256();
// Generate the authorization URL.
let (auth_url, csrf_token, nonce) =
client.authorize_url(pkce_challenge);
session
.insert(
SessionOidcLinkInfo::KEY,
SessionOidcLinkInfo {
user_id,
csrf_token: csrf_token.secret().clone(),
pkce_verifier,
nonce,
},
)
.await
.context("Failed to insert session link info")?;
auth_redirect(auth_url.as_str())
}
/// Applies 'oidc_redirect_host'
fn auth_redirect(auth_url: &str) -> anyhow::Result<Redirect> {
let config = core_config();
let redirect = if !config.oidc_redirect_host.is_empty() {
let auth_url = auth_url.as_str();
let (protocol, rest) = auth_url
.split_once("://")
.context("Invalid URL: Missing protocol (eg 'https://')")?;
@@ -150,22 +189,21 @@ async fn login(
&config.oidc_redirect_host,
))
} else {
Redirect::to(auth_url.as_str())
Redirect::to(auth_url)
};
Ok(redirect)
}
#[derive(Debug, Deserialize)]
struct CallbackQuery {
pub struct OidcCallbackQuery {
state: Option<String>,
code: Option<String>,
error: Option<String>,
}
async fn callback(
Query(query): Query<CallbackQuery>,
session: Session,
Query(query): Query<OidcCallbackQuery>,
) -> anyhow::Result<Redirect> {
let client = oidc_client().load();
let client =
@@ -180,78 +218,58 @@ async fn callback(
query.state.context("Provider did not return state")?,
);
let (_, (pkce_verifier, nonce, redirect, valid_until)) =
verifier_tokens()
.remove(state.secret())
.context("CSRF token invalid")?;
if komodo_timestamp() > valid_until {
return Err(anyhow!(
"CSRF token invalid (Timed out). The token must be used within 2 minutes."
));
}
let reqwest_client = reqwest_client();
let token_response = client
.exchange_code(AuthorizationCode::new(code))
.context("Failed to get Oauth token at exchange code")?
.set_pkce_verifier(pkce_verifier)
.request_async(reqwest_client)
.await
.context("Failed to get Oauth token")?;
// Extract the ID token claims after verifying its authenticity and nonce.
let id_token = token_response
.id_token()
.context("OIDC Server did not return an ID token")?;
// Some providers attach additional audiences, they must be added here
// so token verification succeeds.
let verifier = client.id_token_verifier();
let additional_audiences = &core_config().oidc_additional_audiences;
let verifier = if additional_audiences.is_empty() {
verifier
} else {
verifier.set_other_audience_verifier_fn(|aud| {
additional_audiences.contains(aud)
})
};
let claims = id_token
.claims(&verifier, &nonce)
.context("Failed to verify token claims. This issue may be temporary (60 seconds max).")?;
// Verify the access token hash to ensure that the access token hasn't been substituted for
// another user's.
if let Some(expected_access_token_hash) = claims.access_token_hash()
// Check first if this is a link callback
// and use the linking handler if so.
if let Ok(Some(info)) =
session.remove(SessionOidcLinkInfo::KEY).await
{
let actual_access_token_hash = AccessTokenHash::from_token(
token_response.access_token(),
id_token.signing_alg()?,
id_token.signing_key(&verifier)?,
)?;
if actual_access_token_hash != *expected_access_token_hash {
return Err(anyhow!("Invalid access token"));
}
return link_oidc_callback(client, info, state, code).await;
}
let user_id = claims.subject().as_str();
let db_client = db_client();
let user = db_client
.users
.find_one(doc! {
"config.data.provider": &core_config().oidc_provider,
"config.data.user_id": user_id
})
let SessionOidcVerificationInfo {
csrf_token,
pkce_verifier,
nonce,
redirect,
} = session
.remove(SessionOidcVerificationInfo::KEY)
.await
.context("Failed at find user query from database")?;
.context("Invalid session verification info.")?
.context(
"Missing session verification info for CSRF protection.",
)?;
let (subject, token) = client
.validate_extract_subject_and_token(
(state, csrf_token),
code,
pkce_verifier,
nonce,
)
.await?;
let user = find_oidc_user(&subject).await?;
let user_id_or_two_factor = match user {
Some(user) => {
match (user.passkey.passkey, user.totp.enrolled()) {
match (
user.third_party_skip_2fa,
user.passkey.passkey,
user.totp.enrolled(),
) {
// Skip / No 2FA
(true, _, _) | (false, None, false) => {
session
.insert(
SessionUserId::KEY,
SessionUserId(user.id.clone()),
)
.await
.context("Failed to store user id for client session")?;
UserIdOrTwoFactor::UserId(user.id)
}
// WebAuthn Passkey 2FA
(Some(passkey), _) => {
(false, Some(passkey), _) => {
let webauthn = webauthn().context(
"No webauthn provider available, invalid KOMODO_HOST config",
)?;
@@ -260,28 +278,33 @@ async fn callback(
.context("Failed to start passkey authentication flow")?;
session
.insert(
SESSION_KEY_PASSKEY_LOGIN,
(user.id, server_state),
SessionPasskeyLogin::KEY,
SessionPasskeyLogin {
user_id: user.id,
state: server_state,
},
)
.await?;
UserIdOrTwoFactor::Passkey(response)
}
// TOTP 2FA
(None, true) => {
(false, None, true) => {
session
.insert(SESSION_KEY_TOTP_LOGIN, user.id)
.insert(
SessionTotpLogin::KEY,
SessionTotpLogin { user_id: user.id },
)
.await
.context(
"Failed to store totp login state in for user session",
)?;
UserIdOrTwoFactor::Totp {}
}
// No 2FA
(None, false) => UserIdOrTwoFactor::UserId(user.id),
}
}
None => {
let ts = komodo_timestamp();
let db_client = db_client();
let no_users_exist =
db_client.users.find_one(Document::new()).await?.is_none();
let core_config = core_config();
@@ -290,17 +313,8 @@ async fn callback(
}
// Fetch user info
let user_info = client
.user_info(
token_response.access_token().clone(),
claims.subject().clone().into(),
)
.context("Invalid user info request")?
.request_async::<EmptyAdditionalClaims, _, CoreGenderClaim>(
reqwest_client,
)
.await
.context("Failed to fetch user info for new user")?;
let user_info =
client.fetch_user_info(token, subject.clone()).await?;
// Will use preferred_username, then email, then user_id if it isn't available.
let mut username = user_info
@@ -310,7 +324,7 @@ async fn callback(
let email = user_info
.email()
.map(|email| email.as_str())
.unwrap_or(user_id);
.unwrap_or(subject.as_str());
if core_config.oidc_use_full_email {
email
} else {
@@ -348,10 +362,12 @@ async fn callback(
all: Default::default(),
config: UserConfig::Oidc {
provider: core_config.oidc_provider.clone(),
user_id: user_id.to_string(),
user_id: subject.to_string(),
},
linked_logins: Default::default(),
totp: Default::default(),
passkey: Default::default(),
third_party_skip_2fa: Default::default(),
};
let user_id = db_client
@@ -369,11 +385,7 @@ async fn callback(
};
match user_id_or_two_factor {
UserIdOrTwoFactor::UserId(user_id) => {
session
.insert(SESSION_KEY_USER_ID, user_id)
.await
.context("Failed to store user id for client session")?;
UserIdOrTwoFactor::UserId(_) => {
Ok(format_redirect(redirect.as_deref(), "redeem_ready=true"))
}
UserIdOrTwoFactor::Totp {} => {
@@ -390,3 +402,66 @@ async fn callback(
}
}
}
/// This intercepts during the normal oauth callback if
/// 'oidc-link-info' is found on session.
async fn link_oidc_callback(
client: &OidcClient,
SessionOidcLinkInfo {
user_id,
csrf_token,
pkce_verifier,
nonce,
}: SessionOidcLinkInfo,
state: CsrfToken,
code: String,
) -> anyhow::Result<Redirect> {
let (subject, _) = client
.validate_extract_subject_and_token(
(state, csrf_token),
code,
pkce_verifier,
nonce,
)
.await?;
let oidc_provider = &core_config().oidc_provider;
let oidc_user_id = subject.as_str();
// Ensure there are no other existing users with this login linked.
if let Some(existing_user) = find_oidc_user(&subject).await? {
if existing_user.id == user_id {
// Link is already complete, this is a no-op
return Ok(Redirect::to(&format!(
"{}/settings",
core_config().host,
)));
} else {
return Err(anyhow!("Account already linked to another user."));
}
}
let user = get_user(&user_id).await?;
if let UserConfig::Oidc { .. } = &user.config {
return Err(anyhow!(
"User is primary Oidc user, cannot link another Oidc login."
));
}
let update = doc! {
"$set": {
"linked_logins.Oidc.type": "Oidc",
"linked_logins.Oidc.data.provider": oidc_provider,
"linked_logins.Oidc.data.user_id": oidc_user_id,
}
};
update_one_by_id(&db_client().users, &user_id, update, None)
.await
.context(
"Failed to link OIDC login to existing user on database",
)?;
Ok(Redirect::to(&format!("{}/settings", core_config().host,)))
}
+68
View File
@@ -1213,6 +1213,40 @@ async fn execute_execution(
)
.await?
}
Execution::CreateSwarmConfig(req) => {
let req = ExecuteRequest::CreateSwarmConfig(req);
let update = init_execution_update(&req, &user).await?;
let ExecuteRequest::CreateSwarmConfig(req) = req else {
unreachable!()
};
let update_id = update.id.clone();
handle_resolve_result(
req
.resolve(&ExecuteArgs { user, update, id })
.await
.map_err(|e| e.error)
.context("Failed at CreateSwarmConfig"),
&update_id,
)
.await?
}
Execution::RotateSwarmConfig(req) => {
let req = ExecuteRequest::RotateSwarmConfig(req);
let update = init_execution_update(&req, &user).await?;
let ExecuteRequest::RotateSwarmConfig(req) = req else {
unreachable!()
};
let update_id = update.id.clone();
handle_resolve_result(
req
.resolve(&ExecuteArgs { user, update, id })
.await
.map_err(|e| e.error)
.context("Failed at RotateSwarmConfig"),
&update_id,
)
.await?
}
Execution::RemoveSwarmConfigs(req) => {
let req = ExecuteRequest::RemoveSwarmConfigs(req);
let update = init_execution_update(&req, &user).await?;
@@ -1230,6 +1264,40 @@ async fn execute_execution(
)
.await?
}
Execution::CreateSwarmSecret(req) => {
let req = ExecuteRequest::CreateSwarmSecret(req);
let update = init_execution_update(&req, &user).await?;
let ExecuteRequest::CreateSwarmSecret(req) = req else {
unreachable!()
};
let update_id = update.id.clone();
handle_resolve_result(
req
.resolve(&ExecuteArgs { user, update, id })
.await
.map_err(|e| e.error)
.context("Failed at CreateSwarmSecret"),
&update_id,
)
.await?
}
Execution::RotateSwarmSecret(req) => {
let req = ExecuteRequest::RotateSwarmSecret(req);
let update = init_execution_update(&req, &user).await?;
let ExecuteRequest::RotateSwarmSecret(req) = req else {
unreachable!()
};
let update_id = update.id.clone();
handle_resolve_result(
req
.resolve(&ExecuteArgs { user, update, id })
.await
.map_err(|e| e.error)
.context("Failed at RotateSwarmSecret"),
&update_id,
)
.await?
}
Execution::RemoveSwarmSecrets(req) => {
let req = ExecuteRequest::RemoveSwarmSecrets(req);
let update = init_execution_update(&req, &user).await?;
+63
View File
@@ -34,6 +34,7 @@ use komodo_client::{
variable::Variable,
},
};
use openidconnect::SubjectIdentifier;
use crate::{
config::core_config,
@@ -497,3 +498,65 @@ pub async fn get_swarm_or_server(
Ok(SwarmOrServer::Server(server))
}
pub async fn find_github_user(
github_id: &str,
) -> anyhow::Result<Option<User>> {
db_client()
.users
.find_one(doc! {
// Find either primary or linked Github user
"$or": [
// User is primary Github user
{ "config.data.github_id": &github_id },
// User has linked this Github login
{ "linked_logins.Github.data.github_id": &github_id }
]
})
.await
.context("Failed at find user query from database")
}
pub async fn find_google_user(
google_id: &str,
) -> anyhow::Result<Option<User>> {
db_client()
.users
.find_one(doc! {
// Find either primary or linked Google user
"$or": [
// User is primary Google user
{ "config.data.google_id": &google_id },
// User has linked this Google login
{ "linked_logins.Google.data.google_id": &google_id }
]
})
.await
.context("Failed at find user query from database")
}
pub async fn find_oidc_user(
subject: &SubjectIdentifier,
) -> anyhow::Result<Option<User>> {
let oidc_provider = &core_config().oidc_provider;
let oidc_user_id = subject.as_str();
db_client()
.users
.find_one(doc! {
// Find either primary or linked Oidc user
"$or": [
// User is primary Oidc user
{
"config.data.provider": oidc_provider,
"config.data.user_id": oidc_user_id
},
// User has linked this Oidc login
{
"linked_logins.Oidc.data.provider": oidc_provider,
"linked_logins.Oidc.data.user_id": oidc_user_id
}
]
})
.await
.context("Failed at find user query from database")
}
+24
View File
@@ -141,12 +141,36 @@ pub async fn init_execution_update(
resource::get::<Swarm>(&data.swarm).await?.id,
),
),
ExecuteRequest::CreateSwarmConfig(data) => (
Operation::CreateSwarmConfig,
ResourceTarget::Swarm(
resource::get::<Swarm>(&data.swarm).await?.id,
),
),
ExecuteRequest::RotateSwarmConfig(data) => (
Operation::RotateSwarmConfig,
ResourceTarget::Swarm(
resource::get::<Swarm>(&data.swarm).await?.id,
),
),
ExecuteRequest::RemoveSwarmConfigs(data) => (
Operation::RemoveSwarmConfigs,
ResourceTarget::Swarm(
resource::get::<Swarm>(&data.swarm).await?.id,
),
),
ExecuteRequest::CreateSwarmSecret(data) => (
Operation::CreateSwarmSecret,
ResourceTarget::Swarm(
resource::get::<Swarm>(&data.swarm).await?.id,
),
),
ExecuteRequest::RotateSwarmSecret(data) => (
Operation::RotateSwarmSecret,
ResourceTarget::Swarm(
resource::get::<Swarm>(&data.swarm).await?.id,
),
),
ExecuteRequest::RemoveSwarmSecrets(data) => (
Operation::RemoveSwarmSecrets,
ResourceTarget::Swarm(
+4 -26
View File
@@ -17,7 +17,6 @@ use komodo_client::{
image::ImageListItem,
service::SwarmServiceListItem,
stack::SwarmStackListItem,
task::SwarmTaskListItem,
},
komodo_timestamp,
stack::{Stack, StackService, StackServiceNames, StackState},
@@ -341,7 +340,6 @@ fn deployment_alert_sent_cache() -> &'static AlertCache<String> {
pub async fn update_swarm_deployment_cache(
deployments: Vec<Deployment>,
swarm_services: &[SwarmServiceListItem],
swarm_tasks: &[SwarmTaskListItem],
) {
let deployment_status_cache = deployment_status_cache();
for deployment in deployments {
@@ -361,30 +359,10 @@ pub async fn update_swarm_deployment_cache(
.map(|s| s.curr.state);
let current_state = service
.as_ref()
.map(|service| {
let Some(service_id) = &service.id else {
return DeploymentState::Unknown;
};
let tasks = swarm_tasks
.iter()
.filter(|task| {
task
.service_id
.as_ref()
.map(|id| id == service_id)
.unwrap_or_default()
})
.collect::<Vec<_>>();
// If service exists but no tasks, it is unhealthy
if tasks.is_empty() {
return DeploymentState::Unhealthy;
}
for task in tasks {
if task.desired_state != task.state {
return DeploymentState::Unhealthy;
}
}
DeploymentState::Running
.map(|service| match service.state {
SwarmState::Healthy => DeploymentState::Running,
SwarmState::Unhealthy => DeploymentState::Unhealthy,
SwarmState::Unknown => DeploymentState::Unknown,
})
.unwrap_or(DeploymentState::NotDeployed);
deployment_status_cache
-1
View File
@@ -161,7 +161,6 @@ pub async fn update_cache_for_swarm(swarm: &Swarm, force: bool) {
update_swarm_deployment_cache(
resources.deployments,
&lists.services,
&lists.tasks,
)
);
+36
View File
@@ -781,6 +781,24 @@ async fn validate_config(
.await?;
params.swarm = swarm.id;
}
Execution::CreateSwarmConfig(params) => {
let swarm = super::get_check_permissions::<Swarm>(
&params.swarm,
user,
PermissionLevel::Execute.into(),
)
.await?;
params.swarm = swarm.id;
}
Execution::RotateSwarmConfig(params) => {
let swarm = super::get_check_permissions::<Swarm>(
&params.swarm,
user,
PermissionLevel::Execute.into(),
)
.await?;
params.swarm = swarm.id;
}
Execution::RemoveSwarmConfigs(params) => {
let swarm = super::get_check_permissions::<Swarm>(
&params.swarm,
@@ -790,6 +808,24 @@ async fn validate_config(
.await?;
params.swarm = swarm.id;
}
Execution::CreateSwarmSecret(params) => {
let swarm = super::get_check_permissions::<Swarm>(
&params.swarm,
user,
PermissionLevel::Execute.into(),
)
.await?;
params.swarm = swarm.id;
}
Execution::RotateSwarmSecret(params) => {
let swarm = super::get_check_permissions::<Swarm>(
&params.swarm,
user,
PermissionLevel::Execute.into(),
)
.await?;
params.swarm = swarm.id;
}
Execution::RemoveSwarmSecrets(params) => {
let swarm = super::get_check_permissions::<Swarm>(
&params.swarm,
+1 -1
View File
@@ -251,7 +251,7 @@ pub fn webauthn() -> Option<&'static Webauthn> {
// The relying party id (the effective domain)
let rp_id = rp_origin.domain()?;
info!("Using '{rp_id}' as WebAuthn rp_id");
WebauthnBuilder::new(rp_id, &rp_origin)
WebauthnBuilder::new(rp_id, rp_origin)
.inspect_err(|e| warn!("Failed to init webauthn provider | Invalid KOMODO_HOST: could not build webauthn provider builder | {e:?}"))
.ok()?
.build()
+43 -4
View File
@@ -63,14 +63,19 @@ impl ResourceSyncTrait for Deployment {
update: Self::PartialConfig,
) -> anyhow::Result<Self::ConfigDiff> {
let resources = all_resources_cache().load();
// need to replace the server id with name
original.swarm_id = resources
.swarms
.get(&original.swarm_id)
.map(|s| s.name.clone())
.unwrap_or_default();
original.server_id = resources
.servers
.get(&original.server_id)
.map(|s| s.name.clone())
.unwrap_or_default();
// need to replace the build id with name
if let DeploymentImage::Build { build_id, version } =
&original.image
{
@@ -96,13 +101,19 @@ impl ResourceSyncTrait for Stack {
update: Self::PartialConfig,
) -> anyhow::Result<Self::ConfigDiff> {
let resources = all_resources_cache().load();
// Need to replace server id with name
original.swarm_id = resources
.swarms
.get(&original.swarm_id)
.map(|s| s.name.clone())
.unwrap_or_default();
original.server_id = resources
.servers
.get(&original.server_id)
.map(|s| s.name.clone())
.unwrap_or_default();
// Replace linked repo with name
original.linked_repo = resources
.repos
.get(&original.linked_repo)
@@ -721,6 +732,20 @@ impl ResourceSyncTrait for Procedure {
.map(|s| s.name.clone())
.unwrap_or_default();
}
Execution::CreateSwarmConfig(config) => {
config.swarm = resources
.swarms
.get(&config.swarm)
.map(|s| s.name.clone())
.unwrap_or_default();
}
Execution::RotateSwarmConfig(config) => {
config.swarm = resources
.swarms
.get(&config.swarm)
.map(|s| s.name.clone())
.unwrap_or_default();
}
Execution::RemoveSwarmConfigs(config) => {
config.swarm = resources
.swarms
@@ -728,6 +753,20 @@ impl ResourceSyncTrait for Procedure {
.map(|s| s.name.clone())
.unwrap_or_default();
}
Execution::CreateSwarmSecret(config) => {
config.swarm = resources
.swarms
.get(&config.swarm)
.map(|s| s.name.clone())
.unwrap_or_default();
}
Execution::RotateSwarmSecret(config) => {
config.swarm = resources
.swarms
.get(&config.swarm)
.map(|s| s.name.clone())
.unwrap_or_default();
}
Execution::RemoveSwarmSecrets(config) => {
config.swarm = resources
.swarms
+58
View File
@@ -214,6 +214,15 @@ impl ToToml for Swarm {
impl ToToml for Stack {
fn replace_ids(resource: &mut Resource<Self::Config, Self::Info>) {
let all = all_resources_cache().load();
resource.config.swarm_id.clone_from(
all
.swarms
.get(&resource.config.swarm_id)
.map(|s| &s.name)
.unwrap_or(&String::new()),
);
resource.config.server_id.clone_from(
all
.servers
@@ -221,6 +230,7 @@ impl ToToml for Stack {
.map(|s| &s.name)
.unwrap_or(&String::new()),
);
resource.config.linked_repo.clone_from(
all
.repos
@@ -239,6 +249,7 @@ impl ToToml for Stack {
.map(|(key, value)| {
#[allow(clippy::single_match)]
match key.as_str() {
"swarm_id" => return Ok((String::from("swarm"), value)),
"server_id" => return Ok((String::from("server"), value)),
_ => {}
}
@@ -251,6 +262,15 @@ impl ToToml for Stack {
impl ToToml for Deployment {
fn replace_ids(resource: &mut Resource<Self::Config, Self::Info>) {
let all = all_resources_cache().load();
resource.config.swarm_id.clone_from(
all
.swarms
.get(&resource.config.swarm_id)
.map(|s| &s.name)
.unwrap_or(&String::new()),
);
resource.config.server_id.clone_from(
all
.servers
@@ -258,6 +278,7 @@ impl ToToml for Deployment {
.map(|s| &s.name)
.unwrap_or(&String::new()),
);
if let DeploymentImage::Build { build_id, .. } =
&mut resource.config.image
{
@@ -279,6 +300,7 @@ impl ToToml for Deployment {
.into_iter()
.map(|(key, mut value)| {
match key.as_str() {
"swarm_id" => return Ok((String::from("swarm"), value)),
"server_id" => return Ok((String::from("server"), value)),
"image" => {
if let Some(DeploymentImage::Build { version, .. }) =
@@ -868,6 +890,24 @@ impl ToToml for Procedure {
.unwrap_or(&String::new()),
)
}
Execution::CreateSwarmConfig(exec) => {
exec.swarm.clone_from(
all
.swarms
.get(&exec.swarm)
.map(|a| &a.name)
.unwrap_or(&String::new()),
)
}
Execution::RotateSwarmConfig(exec) => {
exec.swarm.clone_from(
all
.swarms
.get(&exec.swarm)
.map(|a| &a.name)
.unwrap_or(&String::new()),
)
}
Execution::RemoveSwarmConfigs(exec) => {
exec.swarm.clone_from(
all
@@ -877,6 +917,24 @@ impl ToToml for Procedure {
.unwrap_or(&String::new()),
)
}
Execution::CreateSwarmSecret(exec) => {
exec.swarm.clone_from(
all
.swarms
.get(&exec.swarm)
.map(|a| &a.name)
.unwrap_or(&String::new()),
)
}
Execution::RotateSwarmSecret(exec) => {
exec.swarm.clone_from(
all
.swarms
.get(&exec.swarm)
.map(|a| &a.name)
.unwrap_or(&String::new()),
)
}
Execution::RemoveSwarmSecrets(exec) => {
exec.swarm.clone_from(
all
+1
View File
@@ -38,6 +38,7 @@ resolver_api.workspace = true
# external
hickory-resolver.workspace = true
serde_yaml_ng.workspace = true
data-encoding.workspace = true
futures-util.workspace = true
tokio-stream.workspace = true
portable-pty.workspace = true
+4
View File
@@ -154,7 +154,11 @@ pub enum PeripheryRequest {
CreateSwarmService(CreateSwarmService),
UpdateSwarmService(UpdateSwarmService),
RemoveSwarmServices(RemoveSwarmServices),
CreateSwarmConfig(CreateSwarmConfig),
RotateSwarmConfig(RotateSwarmConfig),
RemoveSwarmConfigs(RemoveSwarmConfigs),
CreateSwarmSecret(CreateSwarmSecret),
RotateSwarmSecret(RotateSwarmSecret),
RemoveSwarmSecrets(RemoveSwarmSecrets),
// Terminal
+97
View File
@@ -0,0 +1,97 @@
use anyhow::Context as _;
use formatting::format_serror;
use komodo_client::entities::{
docker::config::SwarmConfig, update::Log,
};
use periphery_client::api::swarm::*;
use resolver_api::Resolve;
use crate::{
docker::config::{
create_swarm_config, inspect_swarm_config, remove_swarm_configs,
},
state::docker_client,
};
impl Resolve<crate::api::Args> for InspectSwarmConfig {
async fn resolve(
self,
_: &crate::api::Args,
) -> anyhow::Result<SwarmConfig> {
inspect_swarm_config(&self.config).await
}
}
impl Resolve<crate::api::Args> for CreateSwarmConfig {
#[instrument(
"CreateSwarmConfig",
skip_all,
fields(
id = args.id.to_string(),
core = args.core,
config = self.name,
)
)]
async fn resolve(
self,
args: &crate::api::Args,
) -> anyhow::Result<Log> {
create_swarm_config(&self).await
}
}
impl Resolve<crate::api::Args> for RotateSwarmConfig {
#[instrument(
"RotateSwarmConfig",
skip_all,
fields(
id = args.id.to_string(),
core = args.core,
config = self.config,
)
)]
async fn resolve(
self,
args: &crate::api::Args,
) -> anyhow::Result<Vec<Log>> {
let client = docker_client().load();
let client = client
.iter()
.next()
.context("Could not connect to docker client")?;
let mut logs = Vec::new();
if let Err(e) = client
.rotate_swarm_config(&self.config, self.data, &mut logs)
.await
{
logs.push(Log::error(
"Rotate Swarm Config",
format_serror(&e.into()),
))
}
Ok(logs)
}
}
impl Resolve<crate::api::Args> for RemoveSwarmConfigs {
#[instrument(
"RemoveSwarmConfigs",
skip_all,
fields(
id = args.id.to_string(),
core = args.core,
configs = serde_json::to_string(&self.configs).unwrap_or_else(|e| e.to_string()),
)
)]
async fn resolve(
self,
args: &crate::api::Args,
) -> anyhow::Result<Log> {
Ok(
remove_swarm_configs(self.configs.iter().map(String::as_str))
.await,
)
}
}
+15 -105
View File
@@ -1,23 +1,19 @@
use anyhow::Context as _;
use command::run_komodo_standard_command;
use komodo_client::entities::{
docker::{
SwarmLists, config::SwarmConfig, node::SwarmNode,
secret::SwarmSecret, task::SwarmTask,
},
docker::{SwarmLists, node::SwarmNode, task::SwarmTask},
update::Log,
};
use periphery_client::api::swarm::*;
use resolver_api::Resolve;
use crate::{
docker::{
config::{inspect_swarm_config, list_swarm_configs},
stack::list_swarm_stacks,
},
docker::{config::list_swarm_configs, stack::list_swarm_stacks},
state::docker_client,
};
mod config;
mod secret;
mod service;
mod stack;
@@ -31,23 +27,28 @@ impl Resolve<crate::api::Args> for PollSwarmStatus {
.iter()
.next()
.context("Could not connect to docker client")?;
let (inspect, nodes, tasks, secrets, configs, stacks) = tokio::join!(
let (inspect, nodes, tasks, stacks) = tokio::join!(
client.inspect_swarm(),
client.list_swarm_nodes(),
client.list_swarm_tasks(),
client.list_swarm_secrets(),
list_swarm_configs(),
list_swarm_stacks(),
);
let tasks = tasks.unwrap_or_default();
let services = client.list_swarm_services(&tasks).await;
let services = client
.list_swarm_services(Some(&tasks))
.await
.unwrap_or_default();
let (configs, secrets) = tokio::join!(
list_swarm_configs(&services),
client.list_swarm_secrets(&services),
);
Ok(PollSwarmStatusResponse {
inspect: inspect.ok(),
lists: SwarmLists {
nodes: nodes.unwrap_or_default(),
services: services.unwrap_or_default(),
secrets: secrets.unwrap_or_default(),
services,
configs: configs.unwrap_or_default(),
secrets: secrets.unwrap_or_default(),
stacks: stacks.unwrap_or_default(),
tasks,
},
@@ -179,94 +180,3 @@ impl Resolve<crate::api::Args> for InspectSwarmTask {
client.inspect_swarm_task(&self.task).await
}
}
// ========
// Config
// ========
impl Resolve<crate::api::Args> for InspectSwarmConfig {
async fn resolve(
self,
_: &crate::api::Args,
) -> anyhow::Result<Vec<SwarmConfig>> {
inspect_swarm_config(&self.config).await
}
}
impl Resolve<crate::api::Args> for RemoveSwarmConfigs {
#[instrument(
"RemoveSwarmConfigs",
skip_all,
fields(
id = args.id.to_string(),
core = args.core,
configs = serde_json::to_string(&self.configs).unwrap_or_else(|e| e.to_string()),
)
)]
async fn resolve(
self,
args: &crate::api::Args,
) -> anyhow::Result<Log> {
let mut command = String::from("docker config rm");
for config in self.configs {
command += " ";
command += &config;
}
Ok(
run_komodo_standard_command(
"Remove Swarm Configs",
None,
command,
)
.await,
)
}
}
// ========
// Secret
// ========
impl Resolve<crate::api::Args> for InspectSwarmSecret {
async fn resolve(
self,
_: &crate::api::Args,
) -> anyhow::Result<SwarmSecret> {
let client = docker_client().load();
let client = client
.iter()
.next()
.context("Could not connect to docker client")?;
client.inspect_swarm_secret(&self.secret).await
}
}
impl Resolve<crate::api::Args> for RemoveSwarmSecrets {
#[instrument(
"RemoveSwarmSecrets",
skip_all,
fields(
id = args.id.to_string(),
core = args.core,
secrets = serde_json::to_string(&self.secrets).unwrap_or_else(|e| e.to_string()),
)
)]
async fn resolve(
self,
args: &crate::api::Args,
) -> anyhow::Result<Log> {
let mut command = String::from("docker secret rm");
for secret in self.secrets {
command += " ";
command += &secret;
}
Ok(
run_komodo_standard_command(
"Remove Swarm Secrets",
None,
command,
)
.await,
)
}
}
+100
View File
@@ -0,0 +1,100 @@
use anyhow::Context as _;
use formatting::format_serror;
use komodo_client::entities::{
docker::secret::SwarmSecret, update::Log,
};
use periphery_client::api::swarm::*;
use resolver_api::Resolve;
use crate::{
docker::secret::{create_swarm_secret, remove_swarm_secrets},
state::docker_client,
};
impl Resolve<crate::api::Args> for InspectSwarmSecret {
async fn resolve(
self,
_: &crate::api::Args,
) -> anyhow::Result<SwarmSecret> {
let client = docker_client().load();
let client = client
.iter()
.next()
.context("Could not connect to docker client")?;
client.inspect_swarm_secret(&self.secret).await
}
}
impl Resolve<crate::api::Args> for CreateSwarmSecret {
#[instrument(
"CreateSwarmSecret",
skip_all,
fields(
id = args.id.to_string(),
core = args.core,
config = self.name,
)
)]
async fn resolve(
self,
args: &crate::api::Args,
) -> anyhow::Result<Log> {
create_swarm_secret(&self).await
}
}
impl Resolve<crate::api::Args> for RotateSwarmSecret {
#[instrument(
"RotateSwarmSecret",
skip_all,
fields(
id = args.id.to_string(),
core = args.core,
secret = self.secret,
)
)]
async fn resolve(
self,
args: &crate::api::Args,
) -> anyhow::Result<Vec<Log>> {
let client = docker_client().load();
let client = client
.iter()
.next()
.context("Could not connect to docker client")?;
let mut logs = Vec::new();
if let Err(e) = client
.rotate_swarm_secret(&self.secret, self.data, &mut logs)
.await
{
logs.push(Log::error(
"Rotate Swarm Secret",
format_serror(&e.into()),
))
}
Ok(logs)
}
}
impl Resolve<crate::api::Args> for RemoveSwarmSecrets {
#[instrument(
"RemoveSwarmSecrets",
skip_all,
fields(
id = args.id.to_string(),
core = args.core,
secrets = serde_json::to_string(&self.secrets).unwrap_or_else(|e| e.to_string()),
)
)]
async fn resolve(
self,
args: &crate::api::Args,
) -> anyhow::Result<Log> {
Ok(
remove_swarm_secrets(self.secrets.iter().map(String::as_str))
.await,
)
}
}
+36 -4
View File
@@ -9,7 +9,7 @@ use formatting::format_serror;
use interpolate::Interpolator;
use komodo_client::entities::{
deployment::{
Deployment, DeploymentConfig, DeploymentImage,
Conversion, Deployment, DeploymentConfig, DeploymentImage,
conversions_from_str, extract_registry_domain,
},
docker::service::SwarmService,
@@ -311,10 +311,9 @@ fn docker_service_create_command(
"-p",
)?;
push_conversions(
push_mounts(
&mut res,
&conversions_from_str(volumes).context("Invalid volumes")?,
"--mount",
)?;
push_environment(
@@ -368,7 +367,7 @@ impl Resolve<crate::api::Args> for UpdateSwarmService {
extra_args,
} = self;
let mut command = format!("docker service update");
let mut command = String::from("docker service update");
if let Some(image) = image {
write!(&mut command, " --image {image}")?;
@@ -419,3 +418,36 @@ impl Resolve<crate::api::Args> for UpdateSwarmService {
Ok(log)
}
}
pub fn push_mounts(
command: &mut String,
mounts: &[Conversion],
) -> anyhow::Result<()> {
for Conversion { local, container } in mounts {
let (typ, src) = if local == "tmpfs" {
("tmpfs", None)
} else if local.starts_with('/') || local.starts_with('.') {
("bind", Some(local))
} else {
("volume", Some(local))
};
let (dst, readonly) =
if let Some((container, mode)) = container.split_once(':') {
(container, mode == "ro")
} else {
(container.as_str(), false)
};
write!(command, " --mount type={typ}")
.context("Failed to format mounts 'type'")?;
if let Some(src) = src {
write!(command, ",src={src}")
.context("Failed to format mounts 'src'")?;
}
write!(command, ",dst={dst}")
.context("Failed to format mounts 'dst'")?;
if readonly {
command.push_str(",ro=true");
}
}
Ok(())
}
+7 -2
View File
@@ -25,9 +25,9 @@ use tracing::Instrument as _;
use crate::{
config::periphery_config,
docker::stack::inspect_swarm_stack,
helpers::push_extra_args,
stack::{maybe_login_registry, validate_files, write::write_stack},
state::docker_client,
};
impl Resolve<crate::api::Args> for InspectSwarmStack {
@@ -35,7 +35,12 @@ impl Resolve<crate::api::Args> for InspectSwarmStack {
self,
_: &crate::api::Args,
) -> anyhow::Result<SwarmStack> {
inspect_swarm_stack(self.stack).await
let client = docker_client().load();
let client = client
.iter()
.next()
.context("Could not connect to docker client")?;
client.inspect_swarm_stack(self.stack).await
}
}
+305 -11
View File
@@ -1,11 +1,26 @@
use anyhow::{Context, anyhow};
use command::run_komodo_standard_command;
use komodo_client::entities::docker::config::{
SwarmConfig, SwarmConfigListItem,
};
use std::fmt::Write;
pub async fn list_swarm_configs()
-> anyhow::Result<Vec<SwarmConfigListItem>> {
use anyhow::{Context, anyhow};
use command::{
run_komodo_shell_command, run_komodo_standard_command,
};
use data_encoding::BASE64URL;
use futures_util::{TryStreamExt as _, stream::FuturesUnordered};
use komodo_client::entities::{
all_logs_success,
docker::{
config::{SwarmConfig, SwarmConfigListItem},
service::{SwarmService, SwarmServiceListItem},
},
random_string,
};
use periphery_client::api::swarm::CreateSwarmConfig;
use super::*;
pub async fn list_swarm_configs(
services: &[SwarmServiceListItem],
) -> anyhow::Result<Vec<SwarmConfigListItem>> {
let res = run_komodo_standard_command(
"List Swarm Configs",
None,
@@ -23,7 +38,21 @@ pub async fn list_swarm_configs()
let mut res = serde_json::from_str::<Vec<SwarmConfigListItem>>(
&format!("[{}]", res.stdout.trim().replace('\n', ",")),
)
.context("Failed to parse 'docker config ls' response from json")?;
.context("Failed to parse 'docker config ls' response from json")?
.into_iter()
.map(|mut res| {
res.in_use = res
.name
.as_ref()
.map(|name| {
services
.iter()
.any(|service| service.configs.contains(name))
})
.unwrap_or_default();
res
})
.collect::<Vec<_>>();
res.sort_by(|a, b| {
a.name
@@ -36,7 +65,7 @@ pub async fn list_swarm_configs()
pub async fn inspect_swarm_config(
config: &str,
) -> anyhow::Result<Vec<SwarmConfig>> {
) -> anyhow::Result<SwarmConfig> {
let res = run_komodo_standard_command(
"Inspect Swarm Config",
None,
@@ -50,7 +79,272 @@ pub async fn inspect_swarm_config(
)));
}
serde_json::from_str(&res.stdout).context(
"Failed to parse 'docker config inspect' response from json",
let mut res = serde_json::from_str::<Vec<SwarmConfig>>(&res.stdout)
.context(
"Failed to parse 'docker config inspect' response from json",
)?
.pop()
.with_context(|| {
format!("Did not find any config matching {config}")
})?;
// Convert data back to readable / editable format
res.spec.iter_mut().next().map(|spec| {
spec.data.iter_mut().next().map(|data| {
if let Ok(res) = BASE64URL
.decode(data.as_bytes())
.map_err(anyhow::Error::new)
.and_then(|data| {
String::from_utf8(data).map_err(anyhow::Error::new)
})
{
*data = res;
}
})
});
Ok(res)
}
pub async fn create_swarm_config(
CreateSwarmConfig {
name,
data,
labels,
template_driver,
}: &CreateSwarmConfig,
) -> anyhow::Result<Log> {
let mut command = String::from("docker config create");
for label in labels {
write!(&mut command, " --label {label}")?;
}
if let Some(driver) = template_driver {
write!(&mut command, " --template-driver {driver}")?;
}
write!(
&mut command,
r#" {name} - <<'EOF'
{}
EOF"#,
data.trim()
)?;
let log =
run_komodo_shell_command("Create Config", None, command).await;
Ok(log)
}
pub async fn remove_swarm_configs(
configs: impl Iterator<Item = &str>,
) -> Log {
let mut command = String::from("docker config rm");
for config in configs {
command += " ";
command += config;
}
run_komodo_standard_command("Remove Swarm Configs", None, command)
.await
}
pub async fn recreate_swarm_config(
config: &CreateSwarmConfig,
logs: &mut Vec<Log>,
) -> anyhow::Result<()> {
let remove =
remove_swarm_configs([config.name.as_str()].into_iter()).await;
let success = remove.success;
logs.push(remove);
if !success {
return Ok(());
}
let log = create_swarm_config(config).await?;
logs.push(log);
Ok(())
}
struct ServiceConfigFile {
/// Service name
service: String,
/// Config file spec
file: TaskSpecContainerSpecFile,
}
impl DockerClient {
pub async fn rotate_swarm_config(
&self,
config: &str,
data: String,
logs: &mut Vec<Log>,
) -> anyhow::Result<()> {
let config = inspect_swarm_config(config).await?;
let config_id = config.id.context("Failed to get config id")?;
let spec = config.spec.context("Failed to get config spec")?;
let name = spec.name.context("Failed to get config name")?;
let labels = spec
.labels
.map(|labels| {
labels
.into_iter()
.map(|(variable, value)| format!("{variable}={value}"))
.collect::<Vec<_>>()
})
.unwrap_or_default();
let template_driver =
spec.templating.map(|templating| templating.name);
let create_config = CreateSwarmConfig {
name,
data,
labels,
template_driver,
};
let services = self
.filter_map_swarm_services(|service| {
extract_from_service(service, &config_id)
})
.await?;
if services.is_empty() {
return recreate_swarm_config(&create_config, logs).await;
}
// Create a tmp config for rotation
let tmp_create_config = CreateSwarmConfig {
name: format!(
"{}-tmp-{}",
create_config.name,
random_string(10)
),
data: create_config.data.clone(),
labels: create_config.labels.clone(),
template_driver: create_config.template_driver.clone(),
};
let log = create_swarm_config(&tmp_create_config).await?;
logs.push(log);
if !all_logs_success(logs) {
return Ok(());
}
// Update services to tmp
switch_services_config(
&services,
&create_config.name,
&tmp_create_config.name,
logs,
)
.await?;
if !all_logs_success(logs) {
return Ok(());
}
// Recreate actual config
recreate_swarm_config(&create_config, logs).await?;
if !all_logs_success(logs) {
return Ok(());
}
// Update back to original
switch_services_config(
&services,
&tmp_create_config.name,
&create_config.name,
logs,
)
.await?;
if !all_logs_success(logs) {
return Ok(());
}
// Remove tmp config
let log = remove_swarm_configs(
[tmp_create_config.name.as_str()].into_iter(),
)
.await;
logs.push(log);
Ok(())
}
}
async fn switch_services_config(
services: &[ServiceConfigFile],
from: &str,
to: &str,
logs: &mut Vec<Log>,
) -> anyhow::Result<()> {
let res = services
.iter()
.map(|service| async move {
switch_service_config(&service.service, from, to, &service.file)
.await
})
.collect::<FuturesUnordered<_>>()
.try_collect::<Vec<_>>()
.await?;
logs.extend(res);
Ok(())
}
async fn switch_service_config(
service: &str,
from: &str,
to: &str,
TaskSpecContainerSpecFile {
name: path,
uid,
gid,
mode,
}: &TaskSpecContainerSpecFile,
) -> anyhow::Result<Log> {
let mut command = format!(
"docker service update --config-rm {from} --config-add source={to}"
);
// Add same file mount options
if let Some(container_path) = path {
write!(&mut command, ",target={container_path}")?;
}
if let Some(uid) = uid {
write!(&mut command, ",uid={uid}")?;
}
if let Some(gid) = gid {
write!(&mut command, ",gid={gid}")?;
}
if let Some(mode) = mode {
write!(&mut command, ",mode={mode}")?;
}
write!(&mut command, " {service}")?;
let log = run_komodo_standard_command(
"Switch Service Config",
None,
command,
)
.await;
Ok(log)
}
fn extract_from_service(
service: SwarmService,
config_id: &str,
) -> Option<ServiceConfigFile> {
let spec = service.spec?;
let configs = spec.task_template?.container_spec?.configs?;
let config = configs.into_iter().find(|cfg| {
cfg
.config_id
.as_ref()
.map(|id| id == config_id)
.unwrap_or_default()
})?;
Some(ServiceConfigFile {
service: spec.name?,
file: config.file?,
})
}
+1 -1
View File
@@ -9,6 +9,7 @@ use komodo_client::entities::{
pub mod compose;
pub mod config;
pub mod secret;
pub mod stack;
pub mod stats;
@@ -16,7 +17,6 @@ mod container;
mod image;
mod network;
mod node;
mod secret;
mod service;
mod swarm;
mod task;
+290 -3
View File
@@ -1,14 +1,29 @@
use std::fmt::Write;
use anyhow::Context;
use bollard::query_parameters::ListSecretsOptions;
use komodo_client::entities::docker::secret::{
SecretSpec, SwarmSecret, SwarmSecretListItem,
use command::{
run_komodo_shell_command, run_komodo_standard_command,
};
use futures_util::{TryStreamExt as _, stream::FuturesUnordered};
use komodo_client::entities::{
all_logs_success,
docker::{
secret::{SecretSpec, SwarmSecret, SwarmSecretListItem},
service::{SwarmService, SwarmServiceListItem},
task::TaskSpecContainerSpecFile,
},
random_string,
update::Log,
};
use periphery_client::api::swarm::CreateSwarmSecret;
use super::DockerClient;
impl DockerClient {
pub async fn list_swarm_secrets(
&self,
services: &[SwarmServiceListItem],
) -> anyhow::Result<Vec<SwarmSecretListItem>> {
let mut secrets = self
.docker
@@ -16,7 +31,7 @@ impl DockerClient {
.await
.context("Failed to query for swarm secret list")?
.into_iter()
.map(convert_secret_list_item)
.map(|secret| convert_secret_list_item(secret, services))
.collect::<Vec<_>>();
secrets.sort_by(|a, b| {
@@ -47,6 +62,7 @@ impl DockerClient {
fn convert_secret_list_item(
secret: bollard::models::Secret,
services: &[SwarmServiceListItem],
) -> SwarmSecretListItem {
let (name, driver, templating) = secret
.spec
@@ -58,11 +74,20 @@ fn convert_secret_list_item(
)
})
.unwrap_or_default();
let in_use = name
.as_ref()
.map(|name| {
services
.iter()
.any(|service| service.secrets.contains(name))
})
.unwrap_or_default();
SwarmSecretListItem {
id: secret.id,
name,
driver,
templating,
in_use,
created_at: secret.created_at,
updated_at: secret.updated_at,
}
@@ -83,3 +108,265 @@ fn convert_secret(secret: bollard::models::Secret) -> SwarmSecret {
}),
}
}
pub async fn create_swarm_secret(
CreateSwarmSecret {
name,
data,
driver,
labels,
template_driver,
}: &CreateSwarmSecret,
) -> anyhow::Result<Log> {
let mut command = String::from("docker secret create");
if let Some(driver) = driver {
write!(&mut command, " --driver {driver}")?;
}
for label in labels {
write!(&mut command, " --label {label}")?;
}
if let Some(driver) = template_driver {
write!(&mut command, " --template-driver {driver}")?;
}
let mut sanitized_command = command.clone();
write!(
&mut command,
r#" {name} - <<'EOF'
{}
EOF"#,
data.trim()
)?;
write!(
&mut sanitized_command,
r#" {name} - <<'EOF'
<secret-data>
EOF"#
)?;
let mut log =
run_komodo_shell_command("Create Secret", None, command).await;
log.command = sanitized_command;
Ok(log)
}
pub async fn remove_swarm_secrets(
secrets: impl Iterator<Item = &str>,
) -> Log {
let mut command = String::from("docker secret rm");
for secret in secrets {
command += " ";
command += secret;
}
run_komodo_standard_command("Remove Swarm Secrets", None, command)
.await
}
pub async fn recreate_swarm_secret(
secret: &CreateSwarmSecret,
logs: &mut Vec<Log>,
) -> anyhow::Result<()> {
let remove =
remove_swarm_secrets([secret.name.as_str()].into_iter()).await;
let success = remove.success;
logs.push(remove);
if !success {
return Ok(());
}
let log = create_swarm_secret(secret).await?;
logs.push(log);
Ok(())
}
struct ServiceSecretFile {
/// Service name
service: String,
/// Secret file spec
file: TaskSpecContainerSpecFile,
}
impl DockerClient {
pub async fn rotate_swarm_secret(
&self,
secret: &str,
data: String,
logs: &mut Vec<Log>,
) -> anyhow::Result<()> {
let secret = self.inspect_swarm_secret(secret).await?;
let secret_id = secret.id.context("Failed to get secret id")?;
let spec = secret.spec.context("Failed to get secret spec")?;
let name = spec.name.context("Failed to get secret name")?;
let driver = spec.driver.map(|driver| driver.name);
let labels = spec
.labels
.map(|labels| {
labels
.into_iter()
.map(|(variable, value)| format!("{variable}={value}"))
.collect::<Vec<_>>()
})
.unwrap_or_default();
let template_driver =
spec.templating.map(|templating| templating.name);
let create_secret = CreateSwarmSecret {
name,
data,
driver,
labels,
template_driver,
};
let services = self
.filter_map_swarm_services(|service| {
extract_from_service(service, &secret_id)
})
.await?;
if services.is_empty() {
return recreate_swarm_secret(&create_secret, logs).await;
}
// Create a tmp secret for rotation
let tmp_create_config = CreateSwarmSecret {
name: format!(
"{}-tmp-{}",
create_secret.name,
random_string(10)
),
data: create_secret.data.clone(),
driver: create_secret.driver.clone(),
labels: create_secret.labels.clone(),
template_driver: create_secret.template_driver.clone(),
};
let log = create_swarm_secret(&tmp_create_config).await?;
logs.push(log);
if !all_logs_success(logs) {
return Ok(());
}
// Update services to tmp
switch_services_secret(
&services,
&create_secret.name,
&tmp_create_config.name,
logs,
)
.await?;
if !all_logs_success(logs) {
return Ok(());
}
// Recreate actual secret
recreate_swarm_secret(&create_secret, logs).await?;
if !all_logs_success(logs) {
return Ok(());
}
// Update back to original
switch_services_secret(
&services,
&tmp_create_config.name,
&create_secret.name,
logs,
)
.await?;
if !all_logs_success(logs) {
return Ok(());
}
// Remove tmp secret
let log = remove_swarm_secrets(
[tmp_create_config.name.as_str()].into_iter(),
)
.await;
logs.push(log);
Ok(())
}
}
async fn switch_services_secret(
services: &[ServiceSecretFile],
from: &str,
to: &str,
logs: &mut Vec<Log>,
) -> anyhow::Result<()> {
let res = services
.iter()
.map(|service| async move {
switch_service_secret(&service.service, from, to, &service.file)
.await
})
.collect::<FuturesUnordered<_>>()
.try_collect::<Vec<_>>()
.await?;
logs.extend(res);
Ok(())
}
async fn switch_service_secret(
service: &str,
from: &str,
to: &str,
TaskSpecContainerSpecFile {
name: path,
uid,
gid,
mode,
}: &TaskSpecContainerSpecFile,
) -> anyhow::Result<Log> {
let mut command = format!(
"docker service update --secret-rm {from} --secret-add source={to}"
);
// Add same file mount options
if let Some(container_path) = path {
write!(&mut command, ",target={container_path}")?;
}
if let Some(uid) = uid {
write!(&mut command, ",uid={uid}")?;
}
if let Some(gid) = gid {
write!(&mut command, ",gid={gid}")?;
}
if let Some(mode) = mode {
write!(&mut command, ",mode={mode}")?;
}
write!(&mut command, " {service}")?;
let log = run_komodo_standard_command(
"Switch Service Secret",
None,
command,
)
.await;
Ok(log)
}
fn extract_from_service(
service: SwarmService,
secret_id: &str,
) -> Option<ServiceSecretFile> {
let spec = service.spec?;
let secrets = spec.task_template?.container_spec?.secrets?;
let secret = secrets.into_iter().find(|cfg| {
cfg
.secret_id
.as_ref()
.map(|id| id == secret_id)
.unwrap_or_default()
})?;
Some(ServiceSecretFile {
service: spec.name?,
file: secret.file?,
})
}
+105 -30
View File
@@ -2,6 +2,7 @@ use anyhow::Context;
use bollard::query_parameters::{
InspectServiceOptions, ListServicesOptions,
};
use futures_util::{TryStreamExt as _, stream::FuturesUnordered};
use komodo_client::entities::{
docker::{NetworkAttachmentConfig, service::*},
swarm::SwarmState,
@@ -13,7 +14,7 @@ impl DockerClient {
/// List swarm services
pub async fn list_swarm_services(
&self,
tasks: &[SwarmTaskListItem],
tasks: Option<&[SwarmTaskListItem]>,
) -> anyhow::Result<Vec<SwarmServiceListItem>> {
let mut services = self
.docker
@@ -33,6 +34,30 @@ impl DockerClient {
Ok(services)
}
pub async fn filter_map_swarm_services<T>(
&self,
filter_map: impl Fn(SwarmService) -> Option<T>,
) -> anyhow::Result<Vec<T>> {
let res = self
.list_swarm_services(None)
.await?
.into_iter()
.map(|service| async {
let Some(name) = service.name else {
return Ok(None);
};
let service = self.inspect_swarm_service(&name).await?;
anyhow::Ok(filter_map(service))
})
.collect::<FuturesUnordered<_>>()
.try_collect::<Vec<_>>()
.await?
.into_iter()
.flatten()
.collect::<Vec<_>>();
Ok(res)
}
pub async fn inspect_swarm_service(
&self,
service_name: &str,
@@ -57,37 +82,65 @@ impl DockerClient {
fn convert_service_list_item(
service: bollard::models::Service,
tasks: &[SwarmTaskListItem],
tasks: Option<&[SwarmTaskListItem]>,
) -> SwarmServiceListItem {
let (name, (image, restart, runtime), replicas) = service
.spec
.map(|spec| {
(
spec.name,
spec
.task_template
.map(|template| {
(
template.container_spec.and_then(|spec| spec.image),
template.restart_policy.and_then(|policy| {
policy
.condition
.map(convert_task_spec_restart_policy_condition)
}),
template.runtime,
)
})
.unwrap_or_default(),
spec.mode.and_then(|mode| {
mode.replicated.and_then(|replicated| replicated.replicas)
}),
)
})
.unwrap_or_default();
let (name, ((image, configs, secrets), restart, runtime), replicas) =
service
.spec
.map(|spec| {
(
spec.name,
spec
.task_template
.map(|template| {
(
template
.container_spec
.map(|spec| {
(
spec.image,
spec
.configs
.map(|configs| {
configs
.into_iter()
.filter_map(|config| config.config_name)
.collect::<Vec<_>>()
})
.unwrap_or_default(),
spec
.secrets
.map(|secrets| {
secrets
.into_iter()
.filter_map(|secret| secret.secret_name)
.collect::<Vec<_>>()
})
.unwrap_or_default(),
)
})
.unwrap_or_default(),
template.restart_policy.and_then(|policy| {
policy
.condition
.map(convert_task_spec_restart_policy_condition)
}),
template.runtime,
)
})
.unwrap_or_default(),
spec.mode.and_then(|mode| {
mode.replicated.and_then(|replicated| replicated.replicas)
}),
)
})
.unwrap_or_default();
let state = service
.id
.as_ref()
.map(|service_id| service_state_from_tasks(service_id, tasks))
.and_then(|service_id| {
tasks.map(|tasks| service_state_from_tasks(service_id, tasks))
})
.unwrap_or(SwarmState::Unknown);
SwarmServiceListItem {
id: service.id,
@@ -97,6 +150,8 @@ fn convert_service_list_item(
image,
restart,
runtime,
configs,
secrets,
created_at: service.created_at,
updated_at: service.updated_at,
}
@@ -114,8 +169,28 @@ fn service_state_from_tasks(
.unwrap_or_default()
});
for task in tasks {
if task.state != task.desired_state {
return SwarmState::Unhealthy;
let (Some(state), Some(desired_state)) =
(task.state, task.desired_state)
else {
continue;
};
match (state, desired_state) {
// Both running, healthy
(TaskState::RUNNING, TaskState::RUNNING) => continue,
// Not running when it should be, unhealthy
(_, TaskState::RUNNING) => return SwarmState::Unhealthy,
// Should be shutdown but its running, unhealthy
(TaskState::RUNNING, TaskState::SHUTDOWN) => {
return SwarmState::Unhealthy;
}
// Very likely healthy
(_, TaskState::SHUTDOWN) => continue,
// All others must match
(state, desired) => {
if state != desired {
return SwarmState::Unhealthy;
}
}
}
}
SwarmState::Healthy
+100 -46
View File
@@ -2,27 +2,68 @@ use anyhow::{Context, anyhow};
use command::run_komodo_standard_command;
use futures_util::{StreamExt, stream::FuturesOrdered};
use komodo_client::entities::{
docker::stack::{
SwarmStack, SwarmStackListItem, SwarmStackServiceListItem,
SwarmStackTaskListItem,
docker::{
service::SwarmServiceListItem,
stack::{
SwarmStack, SwarmStackListItem, SwarmStackServiceListItem,
SwarmStackTaskListItem,
},
},
swarm::SwarmState,
};
pub async fn inspect_swarm_stack(
name: String,
) -> anyhow::Result<SwarmStack> {
let (tasks, services) = tokio::try_join!(
list_swarm_stack_tasks(&name),
list_swarm_stack_services(&name)
)?;
let state = state_from_tasks(&tasks);
Ok(SwarmStack {
name,
state,
services,
tasks,
})
use super::*;
impl DockerClient {
pub async fn inspect_swarm_stack(
&self,
name: String,
) -> anyhow::Result<SwarmStack> {
let (service_ids, swarm_tasks, tasks) = tokio::try_join!(
list_swarm_stack_service_ids(&name),
list_swarm_stack_tasks(&name),
self.list_swarm_tasks(),
)?;
let services = self
.list_swarm_services(Some(&tasks))
.await?
.into_iter()
.filter(|service| {
service
.id
.as_ref()
.map(|long_id| {
service_ids
.iter()
// These service ids are shortened coming from docker,
// only need to check that long id starts with short id
.any(|short_id| long_id.starts_with(short_id))
})
.unwrap_or_default()
})
.collect::<Vec<_>>();
let task_ids = swarm_tasks
.into_iter()
.filter_map(|task| task.id)
.collect::<Vec<_>>();
let tasks = tasks
.into_iter()
.filter(|task| {
task
.id
.as_ref()
.map(|id| task_ids.contains(id))
.unwrap_or_default()
})
.collect::<Vec<_>>();
let state = state_from_services(&services);
Ok(SwarmStack {
name,
state,
services,
tasks,
})
}
}
pub async fn list_swarm_stacks()
@@ -72,9 +113,9 @@ pub async fn list_swarm_stacks()
Ok(stacks)
}
pub async fn list_swarm_stack_services(
pub async fn list_swarm_stack_service_ids(
stack: &str,
) -> anyhow::Result<Vec<SwarmStackServiceListItem>> {
) -> anyhow::Result<Vec<String>> {
let res = run_komodo_standard_command(
"List Swarm Stack Services",
None,
@@ -89,18 +130,17 @@ pub async fn list_swarm_stack_services(
}
// The output is in JSONL, need to convert to standard JSON vec.
let mut services =
serde_json::from_str::<Vec<SwarmStackServiceListItem>>(&format!(
"[{}]",
res.stdout.trim().replace('\n', ",")
))
.context(
"Failed to parse 'docker stack services' response from json",
)?;
let ids = serde_json::from_str::<Vec<SwarmStackServiceListItem>>(
&format!("[{}]", res.stdout.trim().replace('\n', ",")),
)
.context(
"Failed to parse 'docker stack services' response from json",
)?
.into_iter()
.filter_map(|service| service.id)
.collect::<Vec<_>>();
services.sort_by(|a, b| a.name.cmp(&b.name));
Ok(services)
Ok(ids)
}
pub async fn list_swarm_stack_tasks(
@@ -120,24 +160,25 @@ pub async fn list_swarm_stack_tasks(
}
// The output is in JSONL, need to convert to standard JSON vec.
let mut tasks =
serde_json::from_str::<Vec<SwarmStackTaskListItem>>(&format!(
"[{}]",
res.stdout.trim().replace('\n', ",")
))
.context(
"Failed to parse 'docker stack ps' response from json",
)?;
tasks.sort_by(|a, b| {
a.desired_state
.cmp(&b.desired_state)
.then_with(|| a.name.cmp(&b.name))
});
let tasks = serde_json::from_str::<Vec<SwarmStackTaskListItem>>(
&format!("[{}]", res.stdout.trim().replace('\n', ",")),
)
.context("Failed to parse 'docker stack ps' response from json")?;
Ok(tasks)
}
pub fn state_from_services<'a>(
services: impl IntoIterator<Item = &'a SwarmServiceListItem>,
) -> SwarmState {
for service in services {
if matches!(service.state, SwarmState::Unhealthy) {
return SwarmState::Unhealthy;
}
}
SwarmState::Healthy
}
pub fn state_from_tasks<'a>(
tasks: impl IntoIterator<Item = &'a SwarmStackTaskListItem>,
) -> SwarmState {
@@ -152,8 +193,21 @@ pub fn state_from_tasks<'a>(
let Some(current) = current.split(" ").next() else {
continue;
};
if current != desired {
return SwarmState::Unhealthy;
match (current, desired.as_str()) {
// Both running, healthy
("Running", "Running") => continue,
// Not running when it should be, unhealthy
(_, "Running") => return SwarmState::Unhealthy,
// Should be shutdown but its running, unhealthy
("Running", "Shutdown") => return SwarmState::Unhealthy,
// Very likely healthy
(_, "Shutdown") => continue,
// All others must match
(current, desired) => {
if current != desired {
return SwarmState::Unhealthy;
}
}
}
}
SwarmState::Healthy
+29
View File
@@ -53,6 +53,33 @@ fn convert_task_list_item(
)
})
.unwrap_or_default();
let (configs, secrets) = task
.spec
.and_then(|spec| {
spec.container_spec.map(|spec| {
(
spec
.configs
.map(|config| {
config
.into_iter()
.filter_map(|config| config.config_name)
.collect::<Vec<_>>()
})
.unwrap_or_default(),
spec
.secrets
.map(|secret| {
secret
.into_iter()
.filter_map(|secret| secret.secret_name)
.collect::<Vec<_>>()
})
.unwrap_or_default(),
)
})
})
.unwrap_or_default();
SwarmTaskListItem {
id: task.id,
name: task.name,
@@ -61,6 +88,8 @@ fn convert_task_list_item(
container_id,
state,
desired_state: task.desired_state.map(convert_task_state),
configs,
secrets,
created_at: task.created_at,
updated_at: task.updated_at,
}
+4
View File
@@ -163,7 +163,11 @@ pub enum Execution {
RemoveSwarmNodes(RemoveSwarmNodes),
RemoveSwarmStacks(RemoveSwarmStacks),
RemoveSwarmServices(RemoveSwarmServices),
CreateSwarmConfig(CreateSwarmConfig),
RotateSwarmConfig(RotateSwarmConfig),
RemoveSwarmConfigs(RemoveSwarmConfigs),
CreateSwarmSecret(CreateSwarmSecret),
RotateSwarmSecret(RotateSwarmSecret),
RemoveSwarmSecrets(RemoveSwarmSecrets),
// MAINTENANCE
+142
View File
@@ -108,6 +108,77 @@ pub struct RemoveSwarmServices {
// = Config =
// ==========
/// `docker config create [OPTIONS] CONFIG file|-`
///
/// https://docs.docker.com/reference/cli/docker/config/create/
#[typeshare]
#[derive(
Serialize,
Deserialize,
Debug,
Clone,
PartialEq,
Resolve,
EmptyTraits,
Parser,
)]
#[empty_traits(KomodoExecuteRequest)]
#[response(Update)]
#[error(serror::Error)]
pub struct CreateSwarmConfig {
/// Name or id
pub swarm: String,
/// The name of the config to create
pub name: String,
/// The data to store in the config
pub data: String,
/// Docker labels to give the config
#[serde(default)]
pub labels: Vec<String>,
/// Optional custom template driver
pub template_driver: Option<String>,
}
//
/// https://docs.docker.com/engine/swarm/configs/#example-rotate-a-config
///
/// Swarm configs / secrets are immutable after creation.
/// This making updating values awkward when you have services actively using them.
/// The following steps allows for config rotation while minimizing downtime.
///
/// 1. Query for all services using the config
/// - If not in use by any services, can simply `remove` and `create` the config.
/// - Otherwise, continue with following steps
/// 2. `Create` config `{config}-tmp` using provided data
/// 3. `Update` services to use `tmp` config
/// 4. `Remove` and `create` the actual config. This is now possible because services are using the tmp config.
/// 5. `Update` services to use actual (not `tmp`) config again.
#[typeshare]
#[derive(
Serialize,
Deserialize,
Debug,
Clone,
PartialEq,
Resolve,
EmptyTraits,
Parser,
)]
#[empty_traits(KomodoExecuteRequest)]
#[response(Update)]
#[error(serror::Error)]
pub struct RotateSwarmConfig {
/// Name or id
pub swarm: String,
/// Config name
pub config: String,
/// The new config data as a string
pub data: String,
}
//
/// `docker config rm CONFIG [CONFIG...]`
///
/// https://docs.docker.com/reference/cli/docker/config/rm/
@@ -136,6 +207,77 @@ pub struct RemoveSwarmConfigs {
// = Secret =
// ==========
/// `docker config create [OPTIONS] CONFIG file|-`
///
/// https://docs.docker.com/reference/cli/docker/config/create/
#[typeshare]
#[derive(
Serialize,
Deserialize,
Debug,
Clone,
PartialEq,
Resolve,
EmptyTraits,
Parser,
)]
#[empty_traits(KomodoExecuteRequest)]
#[response(Update)]
#[error(serror::Error)]
pub struct CreateSwarmSecret {
/// Name or id
pub swarm: String,
/// The name of the secret to create
pub name: String,
/// The data to store in the secret
pub data: String,
/// Optional custom secret driver
pub driver: Option<String>,
/// Docker labels to give the secret
#[serde(default)]
pub labels: Vec<String>,
/// Optional custom template driver
pub template_driver: Option<String>,
}
//
/// https://docs.docker.com/engine/swarm/secrets/#example-rotate-a-secret
///
/// Swarm configs / secrets are immutable after creation.
/// This making updating values awkward when you have services actively using them.
/// The following steps allows for secret rotation while minimizing downtime.
///
/// 1. Query for all services using the secret
/// - If not in use by any services, can simply `remove` and `create` the secret.
/// - Otherwise, continue with following steps
/// 2. `Create` secret `{secret}-tmp` using provided data
/// 3. `Update` services to use `tmp` secret
/// 4. `Remove` and `create` the actual secret. This is now possible because services are using the tmp secret.
/// 5. `Update` services to use actual (not `tmp`) secret again.
#[typeshare]
#[derive(
Serialize,
Deserialize,
Debug,
Clone,
PartialEq,
Resolve,
EmptyTraits,
Parser,
)]
#[empty_traits(KomodoExecuteRequest)]
#[response(Update)]
#[error(serror::Error)]
pub struct RotateSwarmSecret {
/// Name or id
pub swarm: String,
/// Secret name
pub secret: String,
/// The new secret data as a string
pub data: String,
}
/// `docker secret rm SECRET [SECRET...]`
///
/// https://docs.docker.com/reference/cli/docker/secret/rm/
+1 -1
View File
@@ -443,7 +443,7 @@ pub struct InspectSwarmConfig {
}
#[typeshare]
pub type InspectSwarmConfigResponse = Vec<SwarmConfig>;
pub type InspectSwarmConfigResponse = SwarmConfig;
//
+104
View File
@@ -48,6 +48,91 @@ pub type SetLastSeenUpdateResponse = NoData;
//
/// Begin linking flow for a third party login. Response: [NoData].
///
/// First call this method when authenticated, then
/// redirect user to /api/auth/{provider}/link.
///
/// 'provider' can be:
/// - github
/// - google
/// - oidc
#[typeshare]
#[derive(
Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits,
)]
#[empty_traits(KomodoUserRequest)]
#[response(BeginThirdPartyLoginLinkResponse)]
#[error(serror::Error)]
pub struct BeginThirdPartyLoginLink {}
#[typeshare]
pub type BeginThirdPartyLoginLinkResponse = NoData;
//
/// Unlink a login. Response: [NoData].
#[typeshare]
#[derive(
Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits,
)]
#[empty_traits(KomodoUserRequest)]
#[response(UnlinkLoginResponse)]
#[error(serror::Error)]
pub struct UnlinkLogin {
/// 'provider' can be:
/// - Local
/// - Github
/// - Google
/// - Oidc
pub provider: String,
}
#[typeshare]
pub type UnlinkLoginResponse = NoData;
//
/// Update the calling users username.
/// Response: [NoData].
///
/// Will fail if the new username is invalid or already taken.
#[typeshare]
#[derive(
Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits,
)]
#[empty_traits(KomodoUserRequest)]
#[response(UpdateUsernameResponse)]
#[error(serror::Error)]
pub struct UpdateUsername {
pub username: String,
}
#[typeshare]
pub type UpdateUsernameResponse = NoData;
//
/// Update the calling user's password. Response: [NoData].
///
/// If the User was created using third party login method,
/// using [UpdatePassword] adds or updates the Local linked (additional) login method.
#[typeshare]
#[derive(
Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits,
)]
#[empty_traits(KomodoUserRequest)]
#[response(UpdatePasswordResponse)]
#[error(serror::Error)]
pub struct UpdatePassword {
pub password: String,
}
#[typeshare]
pub type UpdatePasswordResponse = NoData;
//
/// Create an api key for the calling user.
/// Response: [CreateApiKeyResponse].
///
@@ -225,3 +310,22 @@ pub struct UnenrollPasskey {}
/// Response for [UnenrollPasskey].
#[typeshare]
pub type UnenrollPasskeyResponse = NoData;
//
/// Updates's whether user can skip 2fa when logging in using a third party (Oauth / OIDC).
/// Response: [NoData]
#[typeshare]
#[derive(
Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits,
)]
#[empty_traits(KomodoUserRequest)]
#[response(UpdateThirdPartySkip2faResponse)]
#[error(serror::Error)]
pub struct UpdateThirdPartySkip2fa {
pub skip: bool,
}
/// Response for [UpdateThirdPartySkip2fa].
#[typeshare]
pub type UpdateThirdPartySkip2faResponse = NoData;
+1 -39
View File
@@ -3,48 +3,10 @@ use resolver_api::Resolve;
use serde::{Deserialize, Serialize};
use typeshare::typeshare;
use crate::entities::{NoData, user::User};
use crate::entities::user::User;
use super::KomodoWriteRequest;
//
/// **Only for local users**. Update the calling users username.
/// Response: [NoData].
#[typeshare]
#[derive(
Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits,
)]
#[empty_traits(KomodoWriteRequest)]
#[response(UpdateUserUsernameResponse)]
#[error(serror::Error)]
pub struct UpdateUserUsername {
pub username: String,
}
#[typeshare]
pub type UpdateUserUsernameResponse = NoData;
//
/// **Only for local users**. Update the calling users password.
/// Response: [NoData].
#[typeshare]
#[derive(
Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits,
)]
#[empty_traits(KomodoWriteRequest)]
#[response(UpdateUserPasswordResponse)]
#[error(serror::Error)]
pub struct UpdateUserPassword {
pub password: String,
}
#[typeshare]
pub type UpdateUserPasswordResponse = NoData;
//
/// **Admin only**. Delete a user.
/// Admins can delete any non-admin user.
/// Only Super Admin can delete an admin.
@@ -19,6 +19,10 @@ pub struct SwarmConfigListItem {
#[serde(rename = "ID")]
pub id: Option<String>,
/// Whether the config is in use by any service
#[serde(default, rename = "InUse")]
pub in_use: bool,
#[serde(rename = "CreatedAt")]
pub created_at: Option<String>,
@@ -27,6 +27,10 @@ pub struct SwarmSecretListItem {
#[serde(rename = "Templating")]
pub templating: Option<String>,
/// Whether the secret is in use by any service
#[serde(rename = "InUse")]
pub in_use: bool,
#[serde(rename = "CreatedAt")]
pub created_at: Option<String>,
@@ -42,6 +42,14 @@ pub struct SwarmServiceListItem {
#[serde(rename = "Replicas")]
pub replicas: Option<I64>,
/// Attached config names
#[serde(rename = "Configs")]
pub configs: Vec<String>,
/// Attached secret names
#[serde(rename = "Secrets")]
pub secrets: Vec<String>,
/// Swarm service state.
/// - Healthy if all associated tasks match their desired state (or report no desired state)
/// - Unhealthy otherwise
+42 -41
View File
@@ -1,7 +1,10 @@
use serde::{Deserialize, Serialize};
use typeshare::typeshare;
use crate::entities::swarm::SwarmState;
use crate::entities::{
docker::{service::SwarmServiceListItem, task::SwarmTaskListItem},
swarm::SwarmState,
};
/// Swarm stack list item.
/// Returned by `docker stack ls --format json`
@@ -62,11 +65,11 @@ pub struct SwarmStack {
/// Services part of the stack
#[serde(rename = "Services")]
pub services: Vec<SwarmStackServiceListItem>,
pub services: Vec<SwarmServiceListItem>,
/// Tasks part of the stack
#[serde(rename = "Tasks")]
pub tasks: Vec<SwarmStackTaskListItem>,
pub tasks: Vec<SwarmTaskListItem>,
}
/// Swarm stack service list item.
@@ -81,26 +84,25 @@ pub struct SwarmStackServiceListItem {
/// The *short* swarm service ID
#[serde(rename = "ID")]
pub id: Option<String>,
// /// The service name.
// #[serde(rename = "Name")]
// pub name: Option<String>,
/// The service name.
#[serde(rename = "Name")]
pub name: Option<String>,
// /// The service mode.
// #[serde(rename = "Mode")]
// pub mode: Option<String>,
/// The service mode.
#[serde(rename = "Mode")]
pub mode: Option<String>,
// /// The service replicas, formatted as string.
// #[serde(rename = "Replicas")]
// pub replicas: Option<String>,
/// The service replicas, formatted as string.
#[serde(rename = "Replicas")]
pub replicas: Option<String>,
// /// The image associated with service
// #[serde(rename = "Image")]
// pub image: Option<String>,
/// The image associated with service
#[serde(rename = "Image")]
pub image: Option<String>,
/// Task exposed ports, formatted as a string.
#[serde(rename = "Ports")]
pub ports: Option<String>,
// /// Task exposed ports, formatted as a string.
// #[serde(rename = "Ports")]
// pub ports: Option<String>,
}
/// Swarm stack task list item.
@@ -116,31 +118,30 @@ pub struct SwarmStackTaskListItem {
#[serde(rename = "ID")]
pub id: Option<String>,
/// Swarm stack task name.
#[serde(rename = "Name")]
pub name: Option<String>,
/// The image associated with task
#[serde(rename = "Image")]
pub image: Option<String>,
/// The node the task is running on
#[serde(rename = "Node")]
pub node: Option<String>,
/// The task desired state. Matches 'CurrentState' when healthy.
#[serde(rename = "DesiredState")]
pub desired_state: Option<String>,
/// The task current state. Matches 'DesiredState' when healthy.
#[serde(rename = "CurrentState")]
pub current_state: Option<String>,
/// An error message, if one exists
#[serde(rename = "Error")]
pub error: Option<String>,
/// The task desired state. Matches 'CurrentState' when healthy.
#[serde(rename = "DesiredState")]
pub desired_state: Option<String>,
// /// Swarm stack task name.
// #[serde(rename = "Name")]
// pub name: Option<String>,
/// Task exposed ports, formatted as a string.
#[serde(rename = "Ports")]
pub ports: Option<String>,
// /// The image associated with task
// #[serde(rename = "Image")]
// pub image: Option<String>,
// /// The node the task is running on
// #[serde(rename = "Node")]
// pub node: Option<String>,
// /// An error message, if one exists
// #[serde(rename = "Error")]
// pub error: Option<String>,
// /// Task exposed ports, formatted as a string.
// #[serde(rename = "Ports")]
// pub ports: Option<String>,
}
@@ -35,6 +35,14 @@ pub struct SwarmTaskListItem {
#[serde(rename = "DesiredState")]
pub desired_state: Option<TaskState>,
/// Attached config names
#[serde(rename = "Configs")]
pub configs: Vec<String>,
/// Attached secret names
#[serde(rename = "Secrets")]
pub secrets: Vec<String>,
#[serde(rename = "CreatedAt")]
pub created_at: Option<String>,
+6 -1
View File
@@ -354,7 +354,7 @@ impl Version {
#[typeshare]
#[derive(
Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize,
Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, Parser,
)]
pub struct EnvironmentVar {
pub variable: String,
@@ -1063,7 +1063,11 @@ pub enum Operation {
RemoveSwarmNodes,
RemoveSwarmStacks,
RemoveSwarmServices,
CreateSwarmConfig,
RotateSwarmConfig,
RemoveSwarmConfigs,
CreateSwarmSecret,
RotateSwarmSecret,
RemoveSwarmSecrets,
// Server
@@ -1501,6 +1505,7 @@ pub fn resource_link(
format!("{host}{path}")
}
#[allow(clippy::large_enum_variant)]
pub enum SwarmOrServer {
Swarm(swarm::Swarm),
Server(server::Server),
+75 -5
View File
@@ -1,8 +1,11 @@
use std::{collections::HashMap, sync::OnceLock};
use anyhow::anyhow;
use base64urlsafedata::Base64UrlSafeData;
use derive_variants::{EnumVariants, ExtractVariant};
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use strum::{AsRefStr, Display, EnumString};
use typeshare::typeshare;
use webauthn_rs::prelude::Passkey;
@@ -65,9 +68,14 @@ pub struct User {
#[serde(default)]
pub create_build_permissions: bool,
/// The user-type specific config.
/// The primary user login.
pub config: UserConfig,
/// Additional linked login methods.
/// May not contain 'Service' type config.
#[serde(default)]
pub linked_logins: LinkedLoginsMap,
/// TOTP 2fa credentials
#[serde(default)]
pub totp: UserTotpConfig,
@@ -76,6 +84,10 @@ pub struct User {
#[serde(default)]
pub passkey: UserPasskeyConfig,
/// Allow third party logins to skip 2fa.
#[serde(default = "default_third_party_skip_2fa")]
pub third_party_skip_2fa: bool,
/// When the user last opened updates dropdown.
#[serde(default)]
pub last_update_view: I64,
@@ -93,8 +105,27 @@ pub struct User {
pub updated_at: I64,
}
fn default_third_party_skip_2fa() -> bool {
true
}
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, EnumVariants)]
#[variant_derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
PartialOrd,
Ord,
Hash,
Serialize,
Deserialize,
Display,
EnumString,
AsRefStr
)]
#[serde(tag = "type", content = "data")]
pub enum UserConfig {
/// User that logs in with username / password
@@ -121,6 +152,42 @@ impl Default for UserConfig {
}
}
impl UserConfig {
pub fn sanitize(&mut self) {
if let UserConfig::Local { password } = self {
password.clear();
}
}
}
#[typeshare]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct LinkedLoginsMap(HashMap<UserConfigVariant, UserConfig>);
impl LinkedLoginsMap {
pub fn get(
&self,
variant: UserConfigVariant,
) -> Option<&UserConfig> {
self.0.get(&variant)
}
pub fn update(&mut self, login: UserConfig) -> anyhow::Result<()> {
if let UserConfig::Service { .. } = &login {
return Err(anyhow!(
"Cannot insert Service type configuration as additional login method."
));
}
let key = login.extract_variant();
self.0.insert(key, login);
Ok(())
}
pub fn remove(&mut self, variant: UserConfigVariant) {
self.0.remove(&variant);
}
}
#[typeshare]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct UserTotpConfig {
@@ -160,9 +227,12 @@ impl UserPasskeyConfig {
impl User {
/// Prepares user object for transport by clearing any sensitive fields
pub fn sanitize(&mut self) {
if let UserConfig::Local { password } = &mut self.config {
password.clear();
}
self.config.sanitize();
self
.linked_logins
.0
.values_mut()
.for_each(UserConfig::sanitize);
self.totp.sanitize();
self.passkey.sanitize();
}
+1
View File
@@ -30,6 +30,7 @@ function fix_types() {
.replaceAll("AlerterEndpointVariant", 'AlerterEndpoint["type"]')
.replaceAll("AlertDataVariant", 'AlertData["type"]')
.replaceAll("ServerTemplateConfigVariant", 'ServerTemplateConfig["type"]')
.replaceAll("UserConfigVariant", 'UserConfig["type"]')
// Add '| string' to env vars
.replaceAll("EnvironmentVar[]", "EnvironmentVar[] | string")
.replaceAll("IndexSet", "Array")
+9 -2
View File
@@ -13,6 +13,8 @@ export type AuthResponses = {
export type UserResponses = {
PushRecentlyViewed: Types.PushRecentlyViewedResponse;
SetLastSeenUpdate: Types.SetLastSeenUpdateResponse;
UpdateUsername: Types.UpdateUsernameResponse;
UpdatePassword: Types.UpdatePasswordResponse;
CreateApiKey: Types.CreateApiKeyResponse;
DeleteApiKey: Types.DeleteApiKeyResponse;
BeginTotpEnrollment: Types.BeginTotpEnrollmentResponse;
@@ -21,6 +23,9 @@ export type UserResponses = {
BeginPasskeyEnrollment: Types.BeginPasskeyEnrollmentResponse;
ConfirmPasskeyEnrollment: Types.ConfirmPasskeyEnrollmentResponse;
UnenrollPasskey: Types.UnenrollPasskeyResponse;
BeginThirdPartyLoginLink: Types.BeginThirdPartyLoginLinkResponse;
UnlinkLogin: Types.UnlinkLoginResponse;
UpdateThirdPartySkip2fa: Types.UpdateThirdPartySkip2faResponse;
};
export type ReadResponses = {
@@ -319,8 +324,6 @@ export type WriteResponses = {
// ==== USER ====
CreateLocalUser: Types.CreateLocalUserResponse;
UpdateUserUsername: Types.UpdateUserUsernameResponse;
UpdateUserPassword: Types.UpdateUserPasswordResponse;
DeleteUser: Types.DeleteUserResponse;
// ==== SERVICE USER ====
@@ -454,7 +457,11 @@ export type ExecuteResponses = {
RemoveSwarmNodes: Types.Update;
RemoveSwarmStacks: Types.Update;
RemoveSwarmServices: Types.Update;
CreateSwarmConfig: Types.Update;
RotateSwarmConfig: Types.Update;
RemoveSwarmConfigs: Types.Update;
CreateSwarmSecret: Types.Update;
RotateSwarmSecret: Types.Update;
RemoveSwarmSecrets: Types.Update;
// ==== MAINTENANCE ====
+264 -117
View File
@@ -350,6 +350,8 @@ export type _CreationChallengeResponse = any;
/** Response for [BeginPasskeyEnrollment]. */
export type BeginPasskeyEnrollmentResponse = _CreationChallengeResponse;
export type BeginThirdPartyLoginLinkResponse = NoData;
export enum Operation {
None = "None",
CreateSwarm = "CreateSwarm",
@@ -359,7 +361,11 @@ export enum Operation {
RemoveSwarmNodes = "RemoveSwarmNodes",
RemoveSwarmStacks = "RemoveSwarmStacks",
RemoveSwarmServices = "RemoveSwarmServices",
CreateSwarmConfig = "CreateSwarmConfig",
RotateSwarmConfig = "RotateSwarmConfig",
RemoveSwarmConfigs = "RemoveSwarmConfigs",
CreateSwarmSecret = "CreateSwarmSecret",
RotateSwarmSecret = "RotateSwarmSecret",
RemoveSwarmSecrets = "RemoveSwarmSecrets",
CreateServer = "CreateServer",
UpdateServer = "UpdateServer",
@@ -921,7 +927,11 @@ export type Execution =
| { type: "RemoveSwarmNodes", params: RemoveSwarmNodes }
| { type: "RemoveSwarmStacks", params: RemoveSwarmStacks }
| { type: "RemoveSwarmServices", params: RemoveSwarmServices }
| { type: "CreateSwarmConfig", params: CreateSwarmConfig }
| { type: "RotateSwarmConfig", params: RotateSwarmConfig }
| { type: "RemoveSwarmConfigs", params: RemoveSwarmConfigs }
| { type: "CreateSwarmSecret", params: CreateSwarmSecret }
| { type: "RotateSwarmSecret", params: RotateSwarmSecret }
| { type: "RemoveSwarmSecrets", params: RemoveSwarmSecrets }
| { type: "ClearRepoCache", params: ClearRepoCache }
| { type: "BackupCoreDatabase", params: BackupCoreDatabase }
@@ -1102,6 +1112,8 @@ export type UserConfig =
description: string;
}};
export type LinkedLoginsMap = Record<UserConfig["type"], UserConfig>;
export interface UserTotpConfig {
/** TOTP shared secret, encrypted */
secret: string;
@@ -1138,12 +1150,19 @@ export interface User {
create_server_permissions?: boolean;
/** Whether the user has permission to create builds */
create_build_permissions?: boolean;
/** The user-type specific config. */
/** The primary user login. */
config: UserConfig;
/**
* Additional linked login methods.
* May not contain 'Service' type config.
*/
linked_logins?: LinkedLoginsMap;
/** TOTP 2fa credentials */
totp?: UserTotpConfig;
/** WebAuthn Passkey 2fa credentials */
passkey?: UserPasskeyConfig;
/** Allow third party logins to skip 2fa. */
third_party_skip_2fa: boolean;
/** When the user last opened updates dropdown. */
last_update_view?: I64;
/** Recently viewed ids */
@@ -4176,50 +4195,73 @@ export enum SwarmState {
Unknown = "Unknown",
}
/**
* Swarm stack service list item.
* Returned by `docker stack services --format json <NAME>`
*
* https://docs.docker.com/reference/cli/docker/stack/services/#format
*/
export interface SwarmStackServiceListItem {
/** The *short* swarm service ID */
/** Swarm service list item. */
export interface SwarmServiceListItem {
ID?: string;
/** The service name. */
/** Name of the service. */
Name?: string;
/** The service mode. */
Mode?: string;
/** The service replicas, formatted as string. */
Replicas?: string;
/** The image associated with service */
Image?: string;
/** Task exposed ports, formatted as a string. */
Ports?: string;
/** Runtime is the type of runtime specified for the task executor. */
Runtime?: string;
/** Condition for restart. */
Restart?: TaskSpecRestartPolicyConditionEnum;
/** Number of replicas */
Replicas?: I64;
/** Attached config names */
Configs: string[];
/** Attached secret names */
Secrets: string[];
/**
* Swarm service state.
* - Healthy if all associated tasks match their desired state (or report no desired state)
* - Unhealthy otherwise
*
* Not included in docker cli return, computed by Komodo
*/
State: SwarmState;
CreatedAt?: string;
UpdatedAt?: string;
}
/**
* Swarm stack task list item.
* Returned by `docker stack ps --format json <NAME>`
*
* https://docs.docker.com/reference/cli/docker/stack/ps/#format
*/
export interface SwarmStackTaskListItem {
/** The task ID */
export enum TaskState {
NEW = "new",
ALLOCATED = "allocated",
PENDING = "pending",
ASSIGNED = "assigned",
ACCEPTED = "accepted",
PREPARING = "preparing",
READY = "ready",
STARTING = "starting",
RUNNING = "running",
COMPLETE = "complete",
SHUTDOWN = "shutdown",
FAILED = "failed",
REJECTED = "rejected",
REMOVE = "remove",
ORPHANED = "orphaned",
}
/** Swarm task list item. */
export interface SwarmTaskListItem {
/** The ID of the task. */
ID?: string;
/** Swarm stack task name. */
/** Name of the task. */
Name?: string;
/** The image associated with task */
Image?: string;
/** The node the task is running on */
Node?: string;
/** The task desired state. Matches 'CurrentState' when healthy. */
DesiredState?: string;
/** The task current state. Matches 'DesiredState' when healthy. */
CurrentState?: string;
/** An error message, if one exists */
Error?: string;
/** Task exposed ports, formatted as a string. */
Ports?: string;
/** The ID of the node that this task is on. */
NodeID?: string;
/** The ID of the service this task is part of. */
ServiceID?: string;
/** The ID of container associated with this task. */
ContainerID?: string;
State?: TaskState;
DesiredState?: TaskState;
/** Attached config names */
Configs: string[];
/** Attached secret names */
Secrets: string[];
CreatedAt?: string;
UpdatedAt?: string;
}
/**
@@ -4242,16 +4284,16 @@ export interface SwarmStack {
*/
State: SwarmState;
/** Services part of the stack */
Services: SwarmStackServiceListItem[];
Services: SwarmServiceListItem[];
/** Tasks part of the stack */
Tasks: SwarmStackTaskListItem[];
Tasks: SwarmTaskListItem[];
}
export type InspectStackSwarmInfoResponse = SwarmStack;
export type InspectStackSwarmServiceResponse = SwarmService;
export type InspectSwarmConfigResponse = SwarmConfig[];
export type InspectSwarmConfigResponse = SwarmConfig;
export enum NodeSpecRoleEnum {
EMPTY = "",
@@ -4526,24 +4568,6 @@ export type InspectSwarmServiceResponse = SwarmService;
export type InspectSwarmStackResponse = SwarmStack;
export enum TaskState {
NEW = "new",
ALLOCATED = "allocated",
PENDING = "pending",
ASSIGNED = "assigned",
ACCEPTED = "accepted",
PREPARING = "preparing",
READY = "ready",
STARTING = "starting",
RUNNING = "running",
COMPLETE = "complete",
SHUTDOWN = "shutdown",
FAILED = "failed",
REJECTED = "rejected",
REMOVE = "remove",
ORPHANED = "orphaned",
}
/** represents the status of a container. */
export interface ContainerStatus {
ContainerID?: string;
@@ -5073,31 +5097,6 @@ export type ServerListItem = ResourceListItem<ServerListItemInfo>;
export type ListServersResponse = ServerListItem[];
/** Swarm service list item. */
export interface SwarmServiceListItem {
ID?: string;
/** Name of the service. */
Name?: string;
/** The image associated with service */
Image?: string;
/** Runtime is the type of runtime specified for the task executor. */
Runtime?: string;
/** Condition for restart. */
Restart?: TaskSpecRestartPolicyConditionEnum;
/** Number of replicas */
Replicas?: I64;
/**
* Swarm service state.
* - Healthy if all associated tasks match their desired state (or report no desired state)
* - Unhealthy otherwise
*
* Not included in docker cli return, computed by Komodo
*/
State: SwarmState;
CreatedAt?: string;
UpdatedAt?: string;
}
export interface StackService {
/** The service name */
service: string;
@@ -5204,6 +5203,8 @@ export interface SwarmConfigListItem {
/** User-defined name of the config. */
Name?: string;
ID?: string;
/** Whether the config is in use by any service */
InUse?: boolean;
CreatedAt?: string;
UpdatedAt?: string;
/**
@@ -5249,6 +5250,8 @@ export interface SwarmSecretListItem {
* If no driver is set, no templating is used.
*/
Templating?: string;
/** Whether the secret is in use by any service */
InUse: boolean;
CreatedAt?: string;
UpdatedAt?: string;
}
@@ -5284,24 +5287,6 @@ export interface SwarmStackListItem {
export type ListSwarmStacksResponse = SwarmStackListItem[];
/** Swarm task list item. */
export interface SwarmTaskListItem {
/** The ID of the task. */
ID?: string;
/** Name of the task. */
Name?: string;
/** The ID of the node that this task is on. */
NodeID?: string;
/** The ID of the service this task is part of. */
ServiceID?: string;
/** The ID of container associated with this task. */
ContainerID?: string;
State?: TaskState;
DesiredState?: TaskState;
CreatedAt?: string;
UpdatedAt?: string;
}
export type ListSwarmTasksResponse = SwarmTaskListItem[];
export interface SwarmListItemInfo {
@@ -5481,12 +5466,16 @@ export type UnenrollPasskeyResponse = NoData;
/** Response for [UnenrollTotp]. */
export type UnenrollTotpResponse = NoData;
export type UnlinkLoginResponse = NoData;
export type UpdateDockerRegistryAccountResponse = DockerRegistryAccount;
export type UpdateGitProviderAccountResponse = GitProviderAccount;
export type UpdateOnboardingKeyResponse = OnboardingKey;
export type UpdatePasswordResponse = NoData;
export type UpdatePermissionOnResourceTypeResponse = NoData;
export type UpdatePermissionOnTargetResponse = NoData;
@@ -5497,13 +5486,14 @@ export type UpdateResourceMetaResponse = NoData;
export type UpdateServiceUserDescriptionResponse = User;
/** Response for [UpdateThirdPartySkip2fa]. */
export type UpdateThirdPartySkip2faResponse = NoData;
export type UpdateUserAdminResponse = NoData;
export type UpdateUserBasePermissionsResponse = NoData;
export type UpdateUserPasswordResponse = NoData;
export type UpdateUserUsernameResponse = NoData;
export type UpdateUsernameResponse = NoData;
export type UpdateVariableDescriptionResponse = Variable;
@@ -5862,6 +5852,20 @@ export interface BatchRunProcedure {
export interface BeginPasskeyEnrollment {
}
/**
* Begin linking flow for a third party login. Response: [NoData].
*
* First call this method when authenticated, then
* redirect user to /api/auth/{provider}/link.
*
* 'provider' can be:
* - github
* - google
* - oidc
*/
export interface BeginThirdPartyLoginLink {
}
/**
* Starts enrollment flow for TOTP 2FA auth support.
* Response: [BeginTotpEnrollmentResponse]
@@ -6651,6 +6655,44 @@ export interface CreateSwarm {
config?: _PartialSwarmConfig;
}
/**
* `docker config create [OPTIONS] CONFIG file|-`
*
* https://docs.docker.com/reference/cli/docker/config/create/
*/
export interface CreateSwarmConfig {
/** Name or id */
swarm: string;
/** The name of the config to create */
name: string;
/** The data to store in the config */
data: string;
/** Docker labels to give the config */
labels?: string[];
/** Optional custom template driver */
template_driver?: string;
}
/**
* `docker config create [OPTIONS] CONFIG file|-`
*
* https://docs.docker.com/reference/cli/docker/config/create/
*/
export interface CreateSwarmSecret {
/** Name or id */
swarm: string;
/** The name of the secret to create */
name: string;
/** The data to store in the secret */
data: string;
/** Optional custom secret driver */
driver?: string;
/** Docker labels to give the secret */
labels?: string[];
/** Optional custom template driver */
template_driver?: string;
}
/** Create a tag. Response: [Tag]. */
export interface CreateTag {
/** The name of the tag. */
@@ -9369,6 +9411,54 @@ export interface RotateServerKeys {
server: string;
}
/**
* https://docs.docker.com/engine/swarm/configs/#example-rotate-a-config
*
* Swarm configs / secrets are immutable after creation.
* This making updating values awkward when you have services actively using them.
* The following steps allows for config rotation while minimizing downtime.
*
* 1. Query for all services using the config
* - If not in use by any services, can simply `remove` and `create` the config.
* - Otherwise, continue with following steps
* 2. `Create` config `{config}-tmp` using provided data
* 3. `Update` services to use `tmp` config
* 4. `Remove` and `create` the actual config. This is now possible because services are using the tmp config.
* 5. `Update` services to use actual (not `tmp`) config again.
*/
export interface RotateSwarmConfig {
/** Name or id */
swarm: string;
/** Config name */
config: string;
/** The new config data as a string */
data: string;
}
/**
* https://docs.docker.com/engine/swarm/secrets/#example-rotate-a-secret
*
* Swarm configs / secrets are immutable after creation.
* This making updating values awkward when you have services actively using them.
* The following steps allows for secret rotation while minimizing downtime.
*
* 1. Query for all services using the secret
* - If not in use by any services, can simply `remove` and `create` the secret.
* - Otherwise, continue with following steps
* 2. `Create` secret `{secret}-tmp` using provided data
* 3. `Update` services to use `tmp` secret
* 4. `Remove` and `create` the actual secret. This is now possible because services are using the tmp secret.
* 5. `Update` services to use actual (not `tmp`) secret again.
*/
export interface RotateSwarmSecret {
/** Name or id */
swarm: string;
/** Secret name */
secret: string;
/** The new secret data as a string */
data: string;
}
/** Runs the target Action. Response: [Update] */
export interface RunAction {
/** Id or name */
@@ -9774,6 +9864,32 @@ export interface SwarmConfig {
Spec?: ConfigSpec;
}
/**
* Swarm stack service list item.
* Returned by `docker stack services --format json <NAME>`
*
* https://docs.docker.com/reference/cli/docker/stack/services/#format
*/
export interface SwarmStackServiceListItem {
/** The *short* swarm service ID */
ID?: string;
}
/**
* Swarm stack task list item.
* Returned by `docker stack ps --format json <NAME>`
*
* https://docs.docker.com/reference/cli/docker/stack/ps/#format
*/
export interface SwarmStackTaskListItem {
/** The task ID */
ID?: string;
/** The task current state. Matches 'DesiredState' when healthy. */
CurrentState?: string;
/** The task desired state. Matches 'CurrentState' when healthy. */
DesiredState?: string;
}
/** JSON structure to send new terminal window dimensions */
export interface TerminalResizeMessage {
rows: number;
@@ -9813,6 +9929,18 @@ export interface UnenrollPasskey {
export interface UnenrollTotp {
}
/** Unlink a login. Response: [NoData]. */
export interface UnlinkLogin {
/**
* 'provider' can be:
* - Local
* - Github
* - Google
* - Oidc
*/
provider: string;
}
/** Unpauses all containers on the target server. Response: [Update] */
export interface UnpauseAllContainers {
/** Name or id */
@@ -9989,6 +10117,16 @@ export interface UpdateOnboardingKey {
create_builder?: boolean;
}
/**
* Update the calling user's password. Response: [NoData].
*
* If the User was created using third party login method,
* using [UpdatePassword] adds or updates the Local linked (additional) login method.
*/
export interface UpdatePassword {
password: string;
}
/**
* **Admin only.** Update a user or user groups base permission level on a resource type.
* Response: [NoData].
@@ -10183,6 +10321,14 @@ export interface UpdateTagColor {
color: TagColor;
}
/**
* Updates's whether user can skip 2fa when logging in using a third party (Oauth / OIDC).
* Response: [NoData]
*/
export interface UpdateThirdPartySkip2fa {
skip: boolean;
}
/**
* **Super Admin only.** Update's whether a user is admin.
* Response: [NoData].
@@ -10210,18 +10356,12 @@ export interface UpdateUserBasePermissions {
}
/**
* **Only for local users**. Update the calling users password.
* Update the calling users username.
* Response: [NoData].
*
* Will fail if the new username is invalid or already taken.
*/
export interface UpdateUserPassword {
password: string;
}
/**
* **Only for local users**. Update the calling users username.
* Response: [NoData].
*/
export interface UpdateUserUsername {
export interface UpdateUsername {
username: string;
}
@@ -10391,7 +10531,11 @@ export type ExecuteRequest =
| { type: "RemoveSwarmNodes", params: RemoveSwarmNodes }
| { type: "RemoveSwarmStacks", params: RemoveSwarmStacks }
| { type: "RemoveSwarmServices", params: RemoveSwarmServices }
| { type: "CreateSwarmConfig", params: CreateSwarmConfig }
| { type: "RotateSwarmConfig", params: RotateSwarmConfig }
| { type: "RemoveSwarmConfigs", params: RemoveSwarmConfigs }
| { type: "CreateSwarmSecret", params: CreateSwarmSecret }
| { type: "RotateSwarmSecret", params: RotateSwarmSecret }
| { type: "RemoveSwarmSecrets", params: RemoveSwarmSecrets }
| { type: "ClearRepoCache", params: ClearRepoCache }
| { type: "BackupCoreDatabase", params: BackupCoreDatabase }
@@ -10691,6 +10835,8 @@ export type UserIdOrTwoFactor =
export type UserRequest =
| { type: "PushRecentlyViewed", params: PushRecentlyViewed }
| { type: "SetLastSeenUpdate", params: SetLastSeenUpdate }
| { type: "UpdateUsername", params: UpdateUsername }
| { type: "UpdatePassword", params: UpdatePassword }
| { type: "CreateApiKey", params: CreateApiKey }
| { type: "DeleteApiKey", params: DeleteApiKey }
| { type: "BeginTotpEnrollment", params: BeginTotpEnrollment }
@@ -10698,7 +10844,10 @@ export type UserRequest =
| { type: "UnenrollTotp", params: UnenrollTotp }
| { type: "BeginPasskeyEnrollment", params: BeginPasskeyEnrollment }
| { type: "ConfirmPasskeyEnrollment", params: ConfirmPasskeyEnrollment }
| { type: "UnenrollPasskey", params: UnenrollPasskey };
| { type: "UnenrollPasskey", params: UnenrollPasskey }
| { type: "BeginThirdPartyLoginLink", params: BeginThirdPartyLoginLink }
| { type: "UnlinkLogin", params: UnlinkLogin }
| { type: "UpdateThirdPartySkip2fa", params: UpdateThirdPartySkip2fa };
export type WriteRequest =
| { type: "UpdateResourceMeta", params: UpdateResourceMeta }
@@ -10777,8 +10926,6 @@ export type WriteRequest =
| { type: "UpdateOnboardingKey", params: UpdateOnboardingKey }
| { type: "DeleteOnboardingKey", params: DeleteOnboardingKey }
| { type: "CreateLocalUser", params: CreateLocalUser }
| { type: "UpdateUserUsername", params: UpdateUserUsername }
| { type: "UpdateUserPassword", params: UpdateUserPassword }
| { type: "DeleteUser", params: DeleteUser }
| { type: "CreateServiceUser", params: CreateServiceUser }
| { type: "UpdateServiceUserDescription", params: UpdateServiceUserDescription }
+91 -1
View File
@@ -254,12 +254,58 @@ pub struct InspectSwarmTask {
// ========
#[derive(Debug, Clone, Serialize, Deserialize, Resolve)]
#[response(Vec<SwarmConfig>)]
#[response(SwarmConfig)]
#[error(anyhow::Error)]
pub struct InspectSwarmConfig {
pub config: String,
}
//
/// `docker config create [OPTIONS] CONFIG file|-`
///
/// https://docs.docker.com/reference/cli/docker/config/create/
#[derive(Debug, Clone, Serialize, Deserialize, Resolve)]
#[response(Log)]
#[error(anyhow::Error)]
pub struct CreateSwarmConfig {
/// The name of the config to create
pub name: String,
/// The data to store in the config
pub data: String,
/// Docker labels to give the config
pub labels: Vec<String>,
/// Optional custom template driver
pub template_driver: Option<String>,
}
//
/// https://docs.docker.com/engine/swarm/configs/#example-rotate-a-config
///
/// Swarm configs / secrets are immutable after creation.
/// This making updating values awkward when you have services actively using them.
/// The following steps allows for config rotation while minimizing downtime.
///
/// 1. Query for all services using the config
/// - If not in use by any services, can simply `remove` and `create` the config.
/// - Otherwise, continue with following steps
/// 2. `Create` config `{config}-tmp` using provided data
/// 3. `Update` services to use `tmp` config
/// 4. `Remove` and `create` the actual config. This is now possible because services are using the tmp config.
/// 5. `Update` services to use actual (not `tmp`) config again.
#[derive(Debug, Clone, Serialize, Deserialize, Resolve)]
#[response(Vec<Log>)]
#[error(anyhow::Error)]
pub struct RotateSwarmConfig {
/// Config name or id
pub config: String,
/// The config file data as a string
pub data: String,
}
//
/// `docker config rm CONFIG [CONFIG...]`
///
/// https://docs.docker.com/reference/cli/docker/config/rm/
@@ -281,6 +327,50 @@ pub struct InspectSwarmSecret {
pub secret: String,
}
//
/// `docker secret create [OPTIONS] CONFIG file|-`
///
/// https://docs.docker.com/reference/cli/docker/secret/create/
#[derive(Debug, Clone, Serialize, Deserialize, Resolve)]
#[response(Log)]
#[error(anyhow::Error)]
pub struct CreateSwarmSecret {
/// The name of the secret to create
pub name: String,
/// The data to store in the secret
pub data: String,
/// Use a custom secret driver
pub driver: Option<String>,
/// Docker labels to give the secret
pub labels: Vec<String>,
/// Optional custom template driver
pub template_driver: Option<String>,
}
/// https://docs.docker.com/engine/swarm/secrets/#example-rotate-a-secret
///
/// Swarm configs / secrets are immutable after creation.
/// This making updating values awkward when you have services actively using them.
/// The following steps allows for config rotation while minimizing downtime.
///
/// 1. Query for all services using the secret
/// - If not in use by any services, can simply `remove` and `create` the secret.
/// - Otherwise, continue with following steps
/// 2. `Create` secret `{secret}-tmp` using provided data
/// 3. `Update` services to use `tmp` secret instead of original secret
/// 4. `Remove` and `create` the actual secret. This is now possible because services are using the tmp secret.
/// 5. `Update` services to use actual (not `tmp`) secret again.
#[derive(Debug, Clone, Serialize, Deserialize, Resolve)]
#[response(Vec<Log>)]
#[error(anyhow::Error)]
pub struct RotateSwarmSecret {
/// Secret name or id
pub secret: String,
/// The new secret data as a string
pub data: String,
}
/// `docker secret rm SECRET [SECRET...]`
///
/// https://docs.docker.com/reference/cli/docker/secret/rm/
+3 -3
View File
@@ -21,9 +21,9 @@ default_profile = "Default"
# Default: 14
max_backups = 7
# Options: HorizontalOnly, VeriticalOnly, OutsideOnly, InsideOnly, AllBorders
# Default: HorizontalOnly
table_format = "HorizontalOnly"
# Options: Horizontal, Veritical, Outside, Inside, All
# Default: Horizontal
table_borders = "Horizontal"
[[profile]]
name = "Default"
+9 -2
View File
@@ -11,6 +11,8 @@ export type AuthResponses = {
export type UserResponses = {
PushRecentlyViewed: Types.PushRecentlyViewedResponse;
SetLastSeenUpdate: Types.SetLastSeenUpdateResponse;
UpdateUsername: Types.UpdateUsernameResponse;
UpdatePassword: Types.UpdatePasswordResponse;
CreateApiKey: Types.CreateApiKeyResponse;
DeleteApiKey: Types.DeleteApiKeyResponse;
BeginTotpEnrollment: Types.BeginTotpEnrollmentResponse;
@@ -19,6 +21,9 @@ export type UserResponses = {
BeginPasskeyEnrollment: Types.BeginPasskeyEnrollmentResponse;
ConfirmPasskeyEnrollment: Types.ConfirmPasskeyEnrollmentResponse;
UnenrollPasskey: Types.UnenrollPasskeyResponse;
BeginThirdPartyLoginLink: Types.BeginThirdPartyLoginLinkResponse;
UnlinkLogin: Types.UnlinkLoginResponse;
UpdateThirdPartySkip2fa: Types.UpdateThirdPartySkip2faResponse;
};
export type ReadResponses = {
GetVersion: Types.GetVersionResponse;
@@ -238,8 +243,6 @@ export type WriteResponses = {
UpdateOnboardingKey: Types.UpdateOnboardingKeyResponse;
DeleteOnboardingKey: Types.DeleteOnboardingKeyResponse;
CreateLocalUser: Types.CreateLocalUserResponse;
UpdateUserUsername: Types.UpdateUserUsernameResponse;
UpdateUserPassword: Types.UpdateUserPasswordResponse;
DeleteUser: Types.DeleteUserResponse;
CreateServiceUser: Types.CreateServiceUserResponse;
UpdateServiceUserDescription: Types.UpdateServiceUserDescriptionResponse;
@@ -339,7 +342,11 @@ export type ExecuteResponses = {
RemoveSwarmNodes: Types.Update;
RemoveSwarmStacks: Types.Update;
RemoveSwarmServices: Types.Update;
CreateSwarmConfig: Types.Update;
RotateSwarmConfig: Types.Update;
RemoveSwarmConfigs: Types.Update;
CreateSwarmSecret: Types.Update;
RotateSwarmSecret: Types.Update;
RemoveSwarmSecrets: Types.Update;
ClearRepoCache: Types.Update;
BackupCoreDatabase: Types.Update;
+273 -115
View File
@@ -355,6 +355,7 @@ export type BatchExecutionResponse = BatchExecutionResponseItem[];
export type _CreationChallengeResponse = any;
/** Response for [BeginPasskeyEnrollment]. */
export type BeginPasskeyEnrollmentResponse = _CreationChallengeResponse;
export type BeginThirdPartyLoginLinkResponse = NoData;
export declare enum Operation {
None = "None",
CreateSwarm = "CreateSwarm",
@@ -364,7 +365,11 @@ export declare enum Operation {
RemoveSwarmNodes = "RemoveSwarmNodes",
RemoveSwarmStacks = "RemoveSwarmStacks",
RemoveSwarmServices = "RemoveSwarmServices",
CreateSwarmConfig = "CreateSwarmConfig",
RotateSwarmConfig = "RotateSwarmConfig",
RemoveSwarmConfigs = "RemoveSwarmConfigs",
CreateSwarmSecret = "CreateSwarmSecret",
RotateSwarmSecret = "RotateSwarmSecret",
RemoveSwarmSecrets = "RemoveSwarmSecrets",
CreateServer = "CreateServer",
UpdateServer = "UpdateServer",
@@ -1050,9 +1055,21 @@ export type Execution =
} | {
type: "RemoveSwarmServices";
params: RemoveSwarmServices;
} | {
type: "CreateSwarmConfig";
params: CreateSwarmConfig;
} | {
type: "RotateSwarmConfig";
params: RotateSwarmConfig;
} | {
type: "RemoveSwarmConfigs";
params: RemoveSwarmConfigs;
} | {
type: "CreateSwarmSecret";
params: CreateSwarmSecret;
} | {
type: "RotateSwarmSecret";
params: RotateSwarmSecret;
} | {
type: "RemoveSwarmSecrets";
params: RemoveSwarmSecrets;
@@ -1250,6 +1267,7 @@ export type UserConfig =
description: string;
};
};
export type LinkedLoginsMap = Record<UserConfig["type"], UserConfig>;
export interface UserTotpConfig {
/** TOTP shared secret, encrypted */
secret: string;
@@ -1283,12 +1301,19 @@ export interface User {
create_server_permissions?: boolean;
/** Whether the user has permission to create builds */
create_build_permissions?: boolean;
/** The user-type specific config. */
/** The primary user login. */
config: UserConfig;
/**
* Additional linked login methods.
* May not contain 'Service' type config.
*/
linked_logins?: LinkedLoginsMap;
/** TOTP 2fa credentials */
totp?: UserTotpConfig;
/** WebAuthn Passkey 2fa credentials */
passkey?: UserPasskeyConfig;
/** Allow third party logins to skip 2fa. */
third_party_skip_2fa: boolean;
/** When the user last opened updates dropdown. */
last_update_view?: I64;
/** Recently viewed ids */
@@ -4162,49 +4187,71 @@ export declare enum SwarmState {
/** Unknown case */
Unknown = "Unknown"
}
/**
* Swarm stack service list item.
* Returned by `docker stack services --format json <NAME>`
*
* https://docs.docker.com/reference/cli/docker/stack/services/#format
*/
export interface SwarmStackServiceListItem {
/** The *short* swarm service ID */
/** Swarm service list item. */
export interface SwarmServiceListItem {
ID?: string;
/** The service name. */
/** Name of the service. */
Name?: string;
/** The service mode. */
Mode?: string;
/** The service replicas, formatted as string. */
Replicas?: string;
/** The image associated with service */
Image?: string;
/** Task exposed ports, formatted as a string. */
Ports?: string;
/** Runtime is the type of runtime specified for the task executor. */
Runtime?: string;
/** Condition for restart. */
Restart?: TaskSpecRestartPolicyConditionEnum;
/** Number of replicas */
Replicas?: I64;
/** Attached config names */
Configs: string[];
/** Attached secret names */
Secrets: string[];
/**
* Swarm service state.
* - Healthy if all associated tasks match their desired state (or report no desired state)
* - Unhealthy otherwise
*
* Not included in docker cli return, computed by Komodo
*/
State: SwarmState;
CreatedAt?: string;
UpdatedAt?: string;
}
/**
* Swarm stack task list item.
* Returned by `docker stack ps --format json <NAME>`
*
* https://docs.docker.com/reference/cli/docker/stack/ps/#format
*/
export interface SwarmStackTaskListItem {
/** The task ID */
export declare enum TaskState {
NEW = "new",
ALLOCATED = "allocated",
PENDING = "pending",
ASSIGNED = "assigned",
ACCEPTED = "accepted",
PREPARING = "preparing",
READY = "ready",
STARTING = "starting",
RUNNING = "running",
COMPLETE = "complete",
SHUTDOWN = "shutdown",
FAILED = "failed",
REJECTED = "rejected",
REMOVE = "remove",
ORPHANED = "orphaned"
}
/** Swarm task list item. */
export interface SwarmTaskListItem {
/** The ID of the task. */
ID?: string;
/** Swarm stack task name. */
/** Name of the task. */
Name?: string;
/** The image associated with task */
Image?: string;
/** The node the task is running on */
Node?: string;
/** The task desired state. Matches 'CurrentState' when healthy. */
DesiredState?: string;
/** The task current state. Matches 'DesiredState' when healthy. */
CurrentState?: string;
/** An error message, if one exists */
Error?: string;
/** Task exposed ports, formatted as a string. */
Ports?: string;
/** The ID of the node that this task is on. */
NodeID?: string;
/** The ID of the service this task is part of. */
ServiceID?: string;
/** The ID of container associated with this task. */
ContainerID?: string;
State?: TaskState;
DesiredState?: TaskState;
/** Attached config names */
Configs: string[];
/** Attached secret names */
Secrets: string[];
CreatedAt?: string;
UpdatedAt?: string;
}
/**
* All entities related to docker stack available over CLI.
@@ -4226,13 +4273,13 @@ export interface SwarmStack {
*/
State: SwarmState;
/** Services part of the stack */
Services: SwarmStackServiceListItem[];
Services: SwarmServiceListItem[];
/** Tasks part of the stack */
Tasks: SwarmStackTaskListItem[];
Tasks: SwarmTaskListItem[];
}
export type InspectStackSwarmInfoResponse = SwarmStack;
export type InspectStackSwarmServiceResponse = SwarmService;
export type InspectSwarmConfigResponse = SwarmConfig[];
export type InspectSwarmConfigResponse = SwarmConfig;
export declare enum NodeSpecRoleEnum {
EMPTY = "",
WORKER = "worker",
@@ -4474,23 +4521,6 @@ export interface SwarmSecret {
export type InspectSwarmSecretResponse = SwarmSecret;
export type InspectSwarmServiceResponse = SwarmService;
export type InspectSwarmStackResponse = SwarmStack;
export declare enum TaskState {
NEW = "new",
ALLOCATED = "allocated",
PENDING = "pending",
ASSIGNED = "assigned",
ACCEPTED = "accepted",
PREPARING = "preparing",
READY = "ready",
STARTING = "starting",
RUNNING = "running",
COMPLETE = "complete",
SHUTDOWN = "shutdown",
FAILED = "failed",
REJECTED = "rejected",
REMOVE = "remove",
ORPHANED = "orphaned"
}
/** represents the status of a container. */
export interface ContainerStatus {
ContainerID?: string;
@@ -4949,30 +4979,6 @@ export interface ServerListItemInfo {
}
export type ServerListItem = ResourceListItem<ServerListItemInfo>;
export type ListServersResponse = ServerListItem[];
/** Swarm service list item. */
export interface SwarmServiceListItem {
ID?: string;
/** Name of the service. */
Name?: string;
/** The image associated with service */
Image?: string;
/** Runtime is the type of runtime specified for the task executor. */
Runtime?: string;
/** Condition for restart. */
Restart?: TaskSpecRestartPolicyConditionEnum;
/** Number of replicas */
Replicas?: I64;
/**
* Swarm service state.
* - Healthy if all associated tasks match their desired state (or report no desired state)
* - Unhealthy otherwise
*
* Not included in docker cli return, computed by Komodo
*/
State: SwarmState;
CreatedAt?: string;
UpdatedAt?: string;
}
export interface StackService {
/** The service name */
service: string;
@@ -5072,6 +5078,8 @@ export interface SwarmConfigListItem {
/** User-defined name of the config. */
Name?: string;
ID?: string;
/** Whether the config is in use by any service */
InUse?: boolean;
CreatedAt?: string;
UpdatedAt?: string;
/**
@@ -5112,6 +5120,8 @@ export interface SwarmSecretListItem {
* If no driver is set, no templating is used.
*/
Templating?: string;
/** Whether the secret is in use by any service */
InUse: boolean;
CreatedAt?: string;
UpdatedAt?: string;
}
@@ -5142,23 +5152,6 @@ export interface SwarmStackListItem {
Namespace?: string;
}
export type ListSwarmStacksResponse = SwarmStackListItem[];
/** Swarm task list item. */
export interface SwarmTaskListItem {
/** The ID of the task. */
ID?: string;
/** Name of the task. */
Name?: string;
/** The ID of the node that this task is on. */
NodeID?: string;
/** The ID of the service this task is part of. */
ServiceID?: string;
/** The ID of container associated with this task. */
ContainerID?: string;
State?: TaskState;
DesiredState?: TaskState;
CreatedAt?: string;
UpdatedAt?: string;
}
export type ListSwarmTasksResponse = SwarmTaskListItem[];
export interface SwarmListItemInfo {
/** Servers part of the swarm */
@@ -5313,18 +5306,21 @@ export type SwarmQuery = ResourceQuery<SwarmQuerySpecifics>;
export type UnenrollPasskeyResponse = NoData;
/** Response for [UnenrollTotp]. */
export type UnenrollTotpResponse = NoData;
export type UnlinkLoginResponse = NoData;
export type UpdateDockerRegistryAccountResponse = DockerRegistryAccount;
export type UpdateGitProviderAccountResponse = GitProviderAccount;
export type UpdateOnboardingKeyResponse = OnboardingKey;
export type UpdatePasswordResponse = NoData;
export type UpdatePermissionOnResourceTypeResponse = NoData;
export type UpdatePermissionOnTargetResponse = NoData;
export type UpdateProcedureResponse = Procedure;
export type UpdateResourceMetaResponse = NoData;
export type UpdateServiceUserDescriptionResponse = User;
/** Response for [UpdateThirdPartySkip2fa]. */
export type UpdateThirdPartySkip2faResponse = NoData;
export type UpdateUserAdminResponse = NoData;
export type UpdateUserBasePermissionsResponse = NoData;
export type UpdateUserPasswordResponse = NoData;
export type UpdateUserUsernameResponse = NoData;
export type UpdateUsernameResponse = NoData;
export type UpdateVariableDescriptionResponse = Variable;
export type UpdateVariableIsSecretResponse = Variable;
export type UpdateVariableValueResponse = Variable;
@@ -5638,6 +5634,19 @@ export interface BatchRunProcedure {
*/
export interface BeginPasskeyEnrollment {
}
/**
* Begin linking flow for a third party login. Response: [NoData].
*
* First call this method when authenticated, then
* redirect user to /api/auth/{provider}/link.
*
* 'provider' can be:
* - github
* - google
* - oidc
*/
export interface BeginThirdPartyLoginLink {
}
/**
* Starts enrollment flow for TOTP 2FA auth support.
* Response: [BeginTotpEnrollmentResponse]
@@ -6370,6 +6379,42 @@ export interface CreateSwarm {
/** Optional partial config to initialize the swarm with. */
config?: _PartialSwarmConfig;
}
/**
* `docker config create [OPTIONS] CONFIG file|-`
*
* https://docs.docker.com/reference/cli/docker/config/create/
*/
export interface CreateSwarmConfig {
/** Name or id */
swarm: string;
/** The name of the config to create */
name: string;
/** The data to store in the config */
data: string;
/** Docker labels to give the config */
labels?: string[];
/** Optional custom template driver */
template_driver?: string;
}
/**
* `docker config create [OPTIONS] CONFIG file|-`
*
* https://docs.docker.com/reference/cli/docker/config/create/
*/
export interface CreateSwarmSecret {
/** Name or id */
swarm: string;
/** The name of the secret to create */
name: string;
/** The data to store in the secret */
data: string;
/** Optional custom secret driver */
driver?: string;
/** Docker labels to give the secret */
labels?: string[];
/** Optional custom template driver */
template_driver?: string;
}
/** Create a tag. Response: [Tag]. */
export interface CreateTag {
/** The name of the tag. */
@@ -8825,6 +8870,52 @@ export interface RotateServerKeys {
/** Server Id or name */
server: string;
}
/**
* https://docs.docker.com/engine/swarm/configs/#example-rotate-a-config
*
* Swarm configs / secrets are immutable after creation.
* This making updating values awkward when you have services actively using them.
* The following steps allows for config rotation while minimizing downtime.
*
* 1. Query for all services using the config
* - If not in use by any services, can simply `remove` and `create` the config.
* - Otherwise, continue with following steps
* 2. `Create` config `{config}-tmp` using provided data
* 3. `Update` services to use `tmp` config
* 4. `Remove` and `create` the actual config. This is now possible because services are using the tmp config.
* 5. `Update` services to use actual (not `tmp`) config again.
*/
export interface RotateSwarmConfig {
/** Name or id */
swarm: string;
/** Config name */
config: string;
/** The new config data as a string */
data: string;
}
/**
* https://docs.docker.com/engine/swarm/secrets/#example-rotate-a-secret
*
* Swarm configs / secrets are immutable after creation.
* This making updating values awkward when you have services actively using them.
* The following steps allows for secret rotation while minimizing downtime.
*
* 1. Query for all services using the secret
* - If not in use by any services, can simply `remove` and `create` the secret.
* - Otherwise, continue with following steps
* 2. `Create` secret `{secret}-tmp` using provided data
* 3. `Update` services to use `tmp` secret
* 4. `Remove` and `create` the actual secret. This is now possible because services are using the tmp secret.
* 5. `Update` services to use actual (not `tmp`) secret again.
*/
export interface RotateSwarmSecret {
/** Name or id */
swarm: string;
/** Secret name */
secret: string;
/** The new secret data as a string */
data: string;
}
/** Runs the target Action. Response: [Update] */
export interface RunAction {
/** Id or name */
@@ -9200,6 +9291,30 @@ export interface SwarmConfig {
UpdatedAt?: string;
Spec?: ConfigSpec;
}
/**
* Swarm stack service list item.
* Returned by `docker stack services --format json <NAME>`
*
* https://docs.docker.com/reference/cli/docker/stack/services/#format
*/
export interface SwarmStackServiceListItem {
/** The *short* swarm service ID */
ID?: string;
}
/**
* Swarm stack task list item.
* Returned by `docker stack ps --format json <NAME>`
*
* https://docs.docker.com/reference/cli/docker/stack/ps/#format
*/
export interface SwarmStackTaskListItem {
/** The task ID */
ID?: string;
/** The task current state. Matches 'DesiredState' when healthy. */
CurrentState?: string;
/** The task desired state. Matches 'CurrentState' when healthy. */
DesiredState?: string;
}
/** JSON structure to send new terminal window dimensions */
export interface TerminalResizeMessage {
rows: number;
@@ -9233,6 +9348,17 @@ export interface UnenrollPasskey {
*/
export interface UnenrollTotp {
}
/** Unlink a login. Response: [NoData]. */
export interface UnlinkLogin {
/**
* 'provider' can be:
* - Local
* - Github
* - Google
* - Oidc
*/
provider: string;
}
/** Unpauses all containers on the target server. Response: [Update] */
export interface UnpauseAllContainers {
/** Name or id */
@@ -9397,6 +9523,15 @@ export interface UpdateOnboardingKey {
/** Update whether to create Builder */
create_builder?: boolean;
}
/**
* Update the calling user's password. Response: [NoData].
*
* If the User was created using third party login method,
* using [UpdatePassword] adds or updates the Local linked (additional) login method.
*/
export interface UpdatePassword {
password: string;
}
/**
* **Admin only.** Update a user or user groups base permission level on a resource type.
* Response: [NoData].
@@ -9579,6 +9714,13 @@ export interface UpdateTagColor {
/** The new color for the tag. */
color: TagColor;
}
/**
* Updates's whether user can skip 2fa when logging in using a third party (Oauth / OIDC).
* Response: [NoData]
*/
export interface UpdateThirdPartySkip2fa {
skip: boolean;
}
/**
* **Super Admin only.** Update's whether a user is admin.
* Response: [NoData].
@@ -9604,17 +9746,12 @@ export interface UpdateUserBasePermissions {
create_builds?: boolean;
}
/**
* **Only for local users**. Update the calling users password.
* Update the calling users username.
* Response: [NoData].
*
* Will fail if the new username is invalid or already taken.
*/
export interface UpdateUserPassword {
password: string;
}
/**
* **Only for local users**. Update the calling users username.
* Response: [NoData].
*/
export interface UpdateUserUsername {
export interface UpdateUsername {
username: string;
}
/** **Admin only.** Update variable description. Response: [Variable]. */
@@ -9917,9 +10054,21 @@ export type ExecuteRequest = {
} | {
type: "RemoveSwarmServices";
params: RemoveSwarmServices;
} | {
type: "CreateSwarmConfig";
params: CreateSwarmConfig;
} | {
type: "RotateSwarmConfig";
params: RotateSwarmConfig;
} | {
type: "RemoveSwarmConfigs";
params: RemoveSwarmConfigs;
} | {
type: "CreateSwarmSecret";
params: CreateSwarmSecret;
} | {
type: "RotateSwarmSecret";
params: RotateSwarmSecret;
} | {
type: "RemoveSwarmSecrets";
params: RemoveSwarmSecrets;
@@ -10510,6 +10659,12 @@ export type UserRequest = {
} | {
type: "SetLastSeenUpdate";
params: SetLastSeenUpdate;
} | {
type: "UpdateUsername";
params: UpdateUsername;
} | {
type: "UpdatePassword";
params: UpdatePassword;
} | {
type: "CreateApiKey";
params: CreateApiKey;
@@ -10534,6 +10689,15 @@ export type UserRequest = {
} | {
type: "UnenrollPasskey";
params: UnenrollPasskey;
} | {
type: "BeginThirdPartyLoginLink";
params: BeginThirdPartyLoginLink;
} | {
type: "UnlinkLogin";
params: UnlinkLogin;
} | {
type: "UpdateThirdPartySkip2fa";
params: UpdateThirdPartySkip2fa;
};
export type WriteRequest = {
type: "UpdateResourceMeta";
@@ -10763,12 +10927,6 @@ export type WriteRequest = {
} | {
type: "CreateLocalUser";
params: CreateLocalUser;
} | {
type: "UpdateUserUsername";
params: UpdateUserUsername;
} | {
type: "UpdateUserPassword";
params: UpdateUserPassword;
} | {
type: "DeleteUser";
params: DeleteUser;
+22 -18
View File
@@ -72,7 +72,11 @@ export var Operation;
Operation["RemoveSwarmNodes"] = "RemoveSwarmNodes";
Operation["RemoveSwarmStacks"] = "RemoveSwarmStacks";
Operation["RemoveSwarmServices"] = "RemoveSwarmServices";
Operation["CreateSwarmConfig"] = "CreateSwarmConfig";
Operation["RotateSwarmConfig"] = "RotateSwarmConfig";
Operation["RemoveSwarmConfigs"] = "RemoveSwarmConfigs";
Operation["CreateSwarmSecret"] = "CreateSwarmSecret";
Operation["RotateSwarmSecret"] = "RotateSwarmSecret";
Operation["RemoveSwarmSecrets"] = "RemoveSwarmSecrets";
Operation["CreateServer"] = "CreateServer";
Operation["UpdateServer"] = "UpdateServer";
@@ -589,6 +593,24 @@ export var SwarmState;
/** Unknown case */
SwarmState["Unknown"] = "Unknown";
})(SwarmState || (SwarmState = {}));
export var TaskState;
(function (TaskState) {
TaskState["NEW"] = "new";
TaskState["ALLOCATED"] = "allocated";
TaskState["PENDING"] = "pending";
TaskState["ASSIGNED"] = "assigned";
TaskState["ACCEPTED"] = "accepted";
TaskState["PREPARING"] = "preparing";
TaskState["READY"] = "ready";
TaskState["STARTING"] = "starting";
TaskState["RUNNING"] = "running";
TaskState["COMPLETE"] = "complete";
TaskState["SHUTDOWN"] = "shutdown";
TaskState["FAILED"] = "failed";
TaskState["REJECTED"] = "rejected";
TaskState["REMOVE"] = "remove";
TaskState["ORPHANED"] = "orphaned";
})(TaskState || (TaskState = {}));
export var NodeSpecRoleEnum;
(function (NodeSpecRoleEnum) {
NodeSpecRoleEnum["EMPTY"] = "";
@@ -622,24 +644,6 @@ export var SwarmSpecCaConfigExternalCasProtocolEnum;
SwarmSpecCaConfigExternalCasProtocolEnum["EMPTY"] = "";
SwarmSpecCaConfigExternalCasProtocolEnum["CFSSL"] = "cfssl";
})(SwarmSpecCaConfigExternalCasProtocolEnum || (SwarmSpecCaConfigExternalCasProtocolEnum = {}));
export var TaskState;
(function (TaskState) {
TaskState["NEW"] = "new";
TaskState["ALLOCATED"] = "allocated";
TaskState["PENDING"] = "pending";
TaskState["ASSIGNED"] = "assigned";
TaskState["ACCEPTED"] = "accepted";
TaskState["PREPARING"] = "preparing";
TaskState["READY"] = "ready";
TaskState["STARTING"] = "starting";
TaskState["RUNNING"] = "running";
TaskState["COMPLETE"] = "complete";
TaskState["SHUTDOWN"] = "shutdown";
TaskState["FAILED"] = "failed";
TaskState["REJECTED"] = "rejected";
TaskState["REMOVE"] = "remove";
TaskState["ORPHANED"] = "orphaned";
})(TaskState || (TaskState = {}));
export var PortTypeEnum;
(function (PortTypeEnum) {
PortTypeEnum["EMPTY"] = "";
+3 -1
View File
@@ -59,6 +59,7 @@ export const MonacoEditor = ({
filename,
minHeight,
className,
id,
}: {
value: string | undefined;
onValueChange?: (value: string) => void;
@@ -67,6 +68,7 @@ export const MonacoEditor = ({
readOnly?: boolean;
minHeight?: number;
className?: string;
id?: string;
}) => {
const enable_fancy_toml =
useRead("GetCoreInfo", {}).data?.enable_fancy_toml ?? false;
@@ -161,7 +163,7 @@ export const MonacoEditor = ({
};
return (
<div className={cn("mx-2 my-1 w-full", className)}>
<div id={id} className={cn("mx-2 my-1 w-full", className)}>
<Editor
language={language}
value={value}
+2 -2
View File
@@ -84,8 +84,8 @@ export const ResourcePageHeader = ({
resource: Types.ResourceListItem<unknown> | undefined;
/** Only pass if not passing resource */
name?: string;
state: string | undefined;
status: string | undefined;
state: ReactNode | undefined;
status: ReactNode | undefined;
}) => {
const color = text_color_class_by_intention(intent);
const background = hex_color_by_intention(intent) + "15";
@@ -181,38 +181,67 @@ export const DeploymentComponents: RequiredResourceComponents = {
{ type: "Image" }
>
)?.params.image
: info?.image || "N/A"}
: info?.image.split("@")[0] || "N/A"}
</div>
</div>
);
},
DockerResource: ({ id }) => {
const deployment = useDeployment(id);
const service = useRead(
"ListSwarmServices",
{ swarm: deployment?.info.swarm_id! },
{ enabled: !!deployment?.info.swarm_id }
).data?.find((service) => service.Name === deployment?.name);
if (
!deployment ||
[
Types.DeploymentState.Unknown,
Types.DeploymentState.NotDeployed,
].includes(deployment.info.state)
)
) {
return null;
}
if (deployment.info.swarm_id) {
return (
<SwarmResourceLink
type="Service"
swarm_id={deployment.info.swarm_id}
resource_id={deployment.name}
<div className="flex items-center gap-x-4 gap-y-2 flex-wrap">
<SwarmResourceLink
type="Service"
swarm_id={deployment.info.swarm_id}
resource_id={deployment.name}
name={deployment.name}
/>
{service?.Configs.map((config) => (
<div key={config} className="border-l pl-4 text-sm">
<SwarmResourceLink
type="Config"
swarm_id={deployment.info.swarm_id}
resource_id={config}
name={config}
/>
</div>
))}
{service?.Secrets.map((secret) => (
<div key={secret} className="border-l pl-4 text-sm">
<SwarmResourceLink
type="Secret"
swarm_id={deployment.info.swarm_id}
resource_id={secret}
name={secret}
/>
</div>
))}
</div>
);
} else {
return (
<DockerResourceLink
type="container"
name={deployment.name}
server_id={deployment.info.server_id}
/>
);
}
return (
<DockerResourceLink
type="container"
name={deployment.name}
server_id={deployment.info.server_id}
/>
);
},
Ports: ({ id }) => {
const deployment = useDeployment(id);
@@ -1,8 +1,8 @@
import { Types } from "komodo_client";
import { useDeployment } from ".";
import { useLocalStorage, usePermissions } from "@lib/hooks";
import { useLocalStorage, usePermissions, useRead } from "@lib/hooks";
import { useServer } from "../server";
import { useMemo } from "react";
import { ReactNode, useMemo, useState } from "react";
import {
MobileFriendlyTabsSelector,
TabNoContent,
@@ -11,6 +11,8 @@ import { DeploymentConfig } from "./config";
import { DeploymentLogs } from "./log";
import { DeploymentInspect } from "./inspect";
import { ContainerTerminals } from "@components/terminal/container";
import { SwarmServiceTasksTable } from "../swarm/table";
import { useWebsocketMessages } from "@lib/socket";
export const DeploymentTabs = ({ id }: { id: string }) => {
const deployment = useDeployment(id);
@@ -18,7 +20,7 @@ export const DeploymentTabs = ({ id }: { id: string }) => {
return <DeploymentTabsInner deployment={deployment} />;
};
type DeploymentTabsView = "Config" | "Log" | "Inspect" | "Terminals";
type DeploymentTabsView = "Config" | "Tasks" | "Log" | "Inspect" | "Terminals";
const DeploymentTabsInner = ({
deployment,
@@ -37,18 +39,23 @@ const DeploymentTabsInner = ({
useServer(deployment.info.server_id)?.info.container_terminals_disabled ??
false;
const state = deployment.info.state;
const downOrUnknown =
state === undefined ||
state === Types.DeploymentState.Unknown ||
state === Types.DeploymentState.NotDeployed;
const logsDisabled = !specificLogs || downOrUnknown;
const inspectDisabled = !specificInspect || downOrUnknown;
const terminalDisabled =
!specificTerminal ||
container_terminals_disabled ||
state !== Types.DeploymentState.Running;
const view =
(logsDisabled && _view === "Log") ||
(downOrUnknown && _view === "Tasks") ||
(inspectDisabled && _view === "Inspect") ||
(terminalDisabled && _view === "Terminals")
? "Config"
@@ -59,6 +66,11 @@ const DeploymentTabsInner = ({
{
value: "Config",
},
{
value: "Tasks",
disabled: downOrUnknown,
hidden: !deployment.info.swarm_id,
},
{
value: "Log",
disabled: logsDisabled,
@@ -98,6 +110,10 @@ const DeploymentTabsInner = ({
switch (view) {
case "Config":
return <DeploymentConfig id={deployment.id} titleOther={Selector} />;
case "Tasks":
return (
<DeploymentTasksTable deployment={deployment} Selector={Selector} />
);
case "Log":
return <DeploymentLogs id={deployment.id} titleOther={Selector} />;
case "Inspect":
@@ -112,3 +128,32 @@ const DeploymentTabsInner = ({
return <ContainerTerminals target={target} titleOther={Selector} />;
}
};
const DeploymentTasksTable = ({
deployment,
Selector,
}: {
deployment: Types.DeploymentListItem;
Selector: ReactNode;
}) => {
const { data, refetch } = useRead("ListSwarmServices", {
swarm: deployment.info.swarm_id,
});
const service = data?.find((service) => service.Name === deployment.name);
useWebsocketMessages(
"deployment-swarm-tasks",
(update) =>
update.operation === Types.Operation.Deploy &&
update.target.id === deployment.id &&
refetch()
);
const _search = useState("");
return (
<SwarmServiceTasksTable
id={deployment.info.swarm_id}
service_id={service?.ID}
titleOther={Selector}
_search={_search}
/>
);
};
@@ -97,7 +97,11 @@ type MinExecutionType = Exclude<
| "RemoveSwarmNodes"
| "RemoveSwarmStacks"
| "RemoveSwarmServices"
| "CreateSwarmConfig"
| "RotateSwarmConfig"
| "RemoveSwarmConfigs"
| "CreateSwarmSecret"
| "RotateSwarmSecret"
| "RemoveSwarmSecrets"
>;
@@ -30,6 +30,11 @@ export const ServerTabs = ({ id }: { id: string }) => {
const terminalDisabled =
!specificTerminal || (server_info?.terminals_disabled ?? true);
const stacks =
useRead("ListStacks", {}).data?.filter(
(stack) => stack.info.server_id === id
) ?? [];
const noStacks = stacks.length === 0;
const deployments =
useRead("ListDeployments", {}).data?.filter(
(deployment) => deployment.info.server_id === id
@@ -40,11 +45,6 @@ export const ServerTabs = ({ id }: { id: string }) => {
(repo) => repo.info.server_id === id
) ?? [];
const noRepos = repos.length === 0;
const stacks =
useRead("ListStacks", {}).data?.filter(
(stack) => stack.info.server_id === id
) ?? [];
const noStacks = stacks.length === 0;
const noResources = noDeployments && noRepos && noStacks;
@@ -88,7 +88,15 @@ export const ServerTabs = ({ id }: { id: string }) => {
case "Docker":
return <ServerInfo id={id} titleOther={Selector} />;
case "Resources":
return <ServerTabsResources id={id} Selector={Selector} />;
return (
<ServerTabsResources
id={id}
stacks={stacks}
deployments={deployments}
repos={repos}
Selector={Selector}
/>
);
case "Terminals":
return <ServerTabsTerminals id={id} Selector={Selector} />;
}
@@ -97,39 +105,22 @@ export const ServerTabs = ({ id }: { id: string }) => {
const ServerTabsResources = ({
Selector,
id,
stacks,
deployments,
repos,
}: {
Selector: ReactNode;
id: string;
stacks: Types.StackListItem[];
deployments: Types.DeploymentListItem[];
repos: Types.RepoListItem[];
}) => {
const is_admin = useUser().data?.admin ?? false;
const disable_non_admin_create =
useRead("GetCoreInfo", {}).data?.disable_non_admin_create ?? true;
const deployments =
useRead("ListDeployments", {}).data?.filter(
(deployment) => deployment.info.server_id === id
) ?? [];
const repos =
useRead("ListRepos", {}).data?.filter(
(repo) => repo.info.server_id === id
) ?? [];
const stacks =
useRead("ListStacks", {}).data?.filter(
(stack) => stack.info.server_id === id
) ?? [];
return (
<Section titleOther={Selector}>
<Section
title="Deployments"
actions={
(is_admin || !disable_non_admin_create) && (
<ResourceComponents.Deployment.New server_id={id} />
)
}
>
<DeploymentTable deployments={deployments} />
</Section>
<Section
title="Stacks"
actions={
@@ -140,6 +131,16 @@ const ServerTabsResources = ({
>
<StackTable stacks={stacks} />
</Section>
<Section
title="Deployments"
actions={
(is_admin || !disable_non_admin_create) && (
<ResourceComponents.Deployment.New server_id={id} />
)
}
>
<DeploymentTable deployments={deployments} />
</Section>
<Section
title="Repos"
actions={
@@ -54,7 +54,7 @@ export const StackLogs = ({
const StackLogsInner = ({
id,
titleOther,
services,
services: __services,
setServices,
useSwarm,
}: {
@@ -64,38 +64,27 @@ const StackLogsInner = ({
setServices: (state: string[] | LocalStorageSetter<string[]>) => void;
useSwarm: boolean;
}) => {
const _services = __services.filter((s) => s.selected).map((s) => s.service);
const services =
useSwarm && !_services.length ? [__services[0].service] : _services;
return (
<LogSection
regular_logs={(timestamps, stream, tail, poll) =>
NoSearchLogs(
id,
services.filter((s) => s.selected).map((s) => s.service),
tail,
timestamps,
stream,
poll
)
NoSearchLogs(id, services, tail, timestamps, stream, poll)
}
search_logs={(timestamps, terms, invert, poll) =>
SearchLogs(
id,
services.filter((s) => s.selected).map((s) => s.service),
terms,
invert,
timestamps,
poll
)
SearchLogs(id, services, terms, invert, timestamps, poll)
}
titleOther={titleOther}
extraParams={
useSwarm ? (
<StackLogsSingleServiceSelector
services={services}
services={__services}
setServices={setServices}
/>
) : (
<StackLogsMultiServiceSelector
services={services}
services={__services}
setServices={setServices}
/>
)
+130 -22
View File
@@ -14,6 +14,8 @@ import { SwarmTable } from "./table";
import {
swarm_state_intention,
stroke_color_class_by_intention,
swarm_node_state_intention,
swarm_task_state_intention,
} from "@lib/color";
import { cn, updateLogToHtml } from "@lib/utils";
import { Types } from "komodo_client";
@@ -24,6 +26,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@ui/tooltip";
import { Card } from "@ui/card";
import { SwarmTabs } from "./tabs";
import { Link } from "react-router-dom";
import { ReactNode } from "react";
export const useSwarm = (id?: string) =>
useRead("ListSwarms", {}, { refetchInterval: 10_000 }).data?.find(
@@ -143,26 +146,123 @@ export type SwarmResourceType =
| "Stack";
export const SWARM_ICONS: {
[type in SwarmResourceType]: React.FC<{ size?: number; className?: string }>;
[type in SwarmResourceType]: React.FC<{
swarm_id?: string;
resource_id?: string;
size?: number;
className?: string;
}>;
} = {
Node: ({ size, className }) => (
<Diamond className={cn(`w-${size} h-${size}`, className)} />
),
Service: ({ size, className }) => (
<FolderCode className={cn(`w-${size} h-${size}`, className)} />
),
Task: ({ size, className }) => (
<ListTodo className={cn(`w-${size} h-${size}`, className)} />
),
Secret: ({ size, className }) => (
<KeyRound className={cn(`w-${size} h-${size}`, className)} />
),
Config: ({ size, className }) => (
<Settings className={cn(`w-${size} h-${size}`, className)} />
),
Stack: ({ size, className }) => (
<SquareStack className={cn(`w-${size} h-${size}`, className)} />
),
Node: ({ swarm_id, resource_id, size, className }) => {
const state = useRead(
"ListSwarmNodes",
{ swarm: swarm_id! },
{ enabled: !!swarm_id }
).data?.find((node) => resource_id && node.ID === resource_id)?.State;
return (
<Diamond
className={cn(
`w-${size} h-${size}`,
stroke_color_class_by_intention(swarm_node_state_intention(state)),
className
)}
/>
);
},
Stack: ({ swarm_id, resource_id, size, className }) => {
const state = useRead(
"ListSwarmStacks",
{ swarm: swarm_id! },
{ enabled: !!swarm_id }
).data?.find((stack) => resource_id && stack.Name === resource_id)?.State;
return (
<SquareStack
className={cn(
`w-${size} h-${size}`,
stroke_color_class_by_intention(swarm_state_intention(state)),
className
)}
/>
);
},
Service: ({ swarm_id, resource_id, size, className }) => {
const state = useRead(
"ListSwarmServices",
{ swarm: swarm_id! },
{ enabled: !!swarm_id }
).data?.find(
(service) =>
resource_id &&
(service.ID === resource_id || service.Name === resource_id)
)?.State;
return (
<FolderCode
className={cn(
`w-${size} h-${size}`,
stroke_color_class_by_intention(swarm_state_intention(state)),
className
)}
/>
);
},
Task: ({ swarm_id, resource_id, size, className }) => {
const task = useRead(
"ListSwarmTasks",
{ swarm: swarm_id! },
{ enabled: !!swarm_id }
).data?.find((task) => resource_id && task.ID === resource_id);
return (
<ListTodo
className={cn(
`w-${size} h-${size}`,
stroke_color_class_by_intention(
swarm_task_state_intention(task?.State, task?.DesiredState)
),
className
)}
/>
);
},
Config: ({ swarm_id, resource_id, size, className }) => {
const inUse = useRead(
"ListSwarmConfigs",
{ swarm: swarm_id! },
{ enabled: !!swarm_id }
).data?.find(
(config) =>
resource_id &&
(config.ID === resource_id || config.Name === resource_id)
)?.InUse;
return (
<Settings
className={cn(
`w-${size} h-${size}`,
stroke_color_class_by_intention(inUse ? "Good" : "Critical"),
className
)}
/>
);
},
Secret: ({ swarm_id, resource_id, size, className }) => {
const inUse = useRead(
"ListSwarmSecrets",
{ swarm: swarm_id! },
{ enabled: !!swarm_id }
).data?.find(
(secret) =>
resource_id &&
(secret.ID === resource_id || secret.Name === resource_id)
)?.InUse;
return (
<KeyRound
className={cn(
`w-${size} h-${size}`,
stroke_color_class_by_intention(inUse ? "Good" : "Critical"),
className
)}
/>
);
},
};
export const SwarmResourceLink = ({
@@ -170,20 +270,28 @@ export const SwarmResourceLink = ({
swarm_id,
resource_id,
name,
extra,
}: {
type: SwarmResourceType;
swarm_id: string;
resource_id: string | undefined;
name: string | undefined;
extra?: ReactNode;
}) => {
const Icon = SWARM_ICONS[type];
return (
<Link
to={`/swarms/${swarm_id}/swarm-${type.toLowerCase()}/${resource_id}`}
className="flex gap-2 items-center hover:underline"
className="flex items-center gap-2 text-sm hover:underline py-1"
>
<Icon size={4} />
{name ?? "Unknown"}
<Icon swarm_id={swarm_id} resource_id={resource_id} size={4} />
<div
title={name}
className="max-w-[250px] lg:max-w-[300px] overflow-hidden overflow-ellipsis break-words text-nowrap"
>
{name ?? "Unknown"}
</div>
{extra && <div className="no-underline">{extra}</div>}
</Link>
);
};
@@ -1,11 +1,26 @@
import { Section } from "@components/layouts";
import { useRead } from "@lib/hooks";
import { useExecute, usePermissions, useRead } from "@lib/hooks";
import { DataTable, SortableHeader } from "@ui/data-table";
import { Dispatch, ReactNode, SetStateAction } from "react";
import { Search } from "lucide-react";
import { Dispatch, ReactNode, SetStateAction, useMemo, useState } from "react";
import { PlusCircle, Search } from "lucide-react";
import { Input } from "@ui/input";
import { filterBySplit } from "@lib/utils";
import { cn, filterBySplit } from "@lib/utils";
import { SwarmResourceLink } from "..";
import { useToast } from "@ui/use-toast";
import { useNavigate } from "react-router-dom";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@ui/dialog";
import { Label } from "@ui/label";
import { Button } from "@ui/button";
import { language_from_path, MonacoEditor } from "@components/monaco";
import { Badge } from "@ui/badge";
export const SwarmConfigs = ({
id,
@@ -32,6 +47,7 @@ export const SwarmConfigs = ({
titleOther={titleOther}
actions={
<div className="flex items-center gap-4 flex-wrap">
<CreateSwarmConfig id={id} />
<div className="relative">
<Search className="w-4 absolute top-[50%] left-3 -translate-y-[50%] text-muted-foreground" />
<Input
@@ -58,8 +74,13 @@ export const SwarmConfigs = ({
<SwarmResourceLink
type="Config"
swarm_id={id}
resource_id={row.original.ID}
resource_id={row.original.Name}
name={row.original.Name}
extra={
!row.original.InUse && (
<Badge variant="destructive">Unused</Badge>
)
}
/>
),
},
@@ -87,3 +108,81 @@ export const SwarmConfigs = ({
</Section>
);
};
const CreateSwarmConfig = ({ id }: { id: string }) => {
const { canExecute } = usePermissions({ type: "Swarm", id });
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
const [data, setData] = useState("");
const { toast } = useToast();
const nav = useNavigate();
const { mutate, isPending } = useExecute("CreateSwarmConfig", {
onSuccess: () => {
toast({ title: "Created swarm config." });
setName("");
setData("");
setOpen(false);
nav(`/swarms/${id}/swarm-config/${name}`);
},
});
const language = useMemo(() => language_from_path(name), [name]);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger>
<Button
className={cn("items-center gap-2", !canExecute && "hidden")}
variant="secondary"
disabled={isPending}
>
New Config <PlusCircle className="w-4 h-4" />
</Button>
</DialogTrigger>
<DialogContent className="w-[90vw] max-w-[800px]">
<DialogHeader>
<DialogTitle>New Swarm Config</DialogTitle>
<DialogDescription>
<p>Enter a unique name and data for the new Swarm Config.</p>
<p>
For better file syntax support, choose a name with appropriate
file extension, like <code>config.yaml</code>
</p>
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-6 py-8">
<div className="flex flex-col gap-2">
<Label htmlFor="config-name">Name</Label>
<Input
id="config-name"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full lg:w-[300px]"
autoFocus
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="config-data">Data</Label>
<MonacoEditor
id="config-data"
value={data}
onValueChange={setData}
language={language}
/>
</div>
</div>
<DialogFooter>
<Button
variant="secondary"
onClick={() => {
mutate({ swarm: id, name, data });
}}
disabled={isPending}
>
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -11,18 +11,16 @@ import { SwarmNodes } from "./nodes";
import { SwarmSecrets } from "./secrets";
import { SwarmServices } from "./services";
import { SwarmTasks } from "./tasks";
import { SwarmInspect } from "./inspect";
import { SwarmConfigs } from "./configs";
import { SwarmStacks } from "./stacks";
type SwarmInfoView =
| "Inspect"
| "Nodes"
| "Stacks"
| "Services"
| "Tasks"
| "Secrets"
| "Configs";
| "Configs"
| "Secrets";
export const SwarmInfo = ({
id,
@@ -35,7 +33,7 @@ export const SwarmInfo = ({
const state = useSwarm(id)?.info.state ?? Types.SwarmState.Unknown;
const [view, setView] = useLocalStorage<SwarmInfoView>(
"swarm-info-view-v1",
"Inspect"
"Nodes"
);
if (state === Types.SwarmState.Unknown) {
@@ -50,9 +48,6 @@ export const SwarmInfo = ({
const tabsNoContent = useMemo<TabNoContent<SwarmInfoView>[]>(
() => [
{
value: "Inspect",
},
{
value: "Nodes",
},
@@ -66,10 +61,10 @@ export const SwarmInfo = ({
value: "Tasks",
},
{
value: "Secrets",
value: "Configs",
},
{
value: "Configs",
value: "Secrets",
},
],
[]
@@ -86,8 +81,6 @@ export const SwarmInfo = ({
const Component = () => {
switch (view) {
case "Inspect":
return <SwarmInspect id={id} titleOther={Selector} />;
case "Nodes":
return <SwarmNodes id={id} titleOther={Selector} _search={_search} />;
case "Stacks":
@@ -98,10 +91,10 @@ export const SwarmInfo = ({
);
case "Tasks":
return <SwarmTasks id={id} titleOther={Selector} _search={_search} />;
case "Secrets":
return <SwarmSecrets id={id} titleOther={Selector} _search={_search} />;
case "Configs":
return <SwarmConfigs id={id} titleOther={Selector} _search={_search} />;
case "Secrets":
return <SwarmSecrets id={id} titleOther={Selector} _search={_search} />;
}
};
@@ -6,6 +6,12 @@ import { Search } from "lucide-react";
import { Input } from "@ui/input";
import { filterBySplit } from "@lib/utils";
import { SwarmResourceLink } from "..";
import { StatusBadge } from "@components/util";
import {
swarm_node_availability_intention,
swarm_node_role_intention,
swarm_node_state_intention,
} from "@lib/color";
export const SwarmNodes = ({
id,
@@ -77,21 +83,38 @@ export const SwarmNodes = ({
header: ({ column }) => (
<SortableHeader column={column} title="Role" />
),
cell: ({ row }) => row.original.Role ?? "Unknown",
},
{
accessorKey: "Availability",
header: ({ column }) => (
<SortableHeader column={column} title="Availability" />
cell: ({ row }) => (
<StatusBadge
text={row.original.Role}
intent={swarm_node_role_intention(row.original.Role)}
/>
),
cell: ({ row }) => row.original.Availability ?? "Unknown",
},
{
accessorKey: "State",
header: ({ column }) => (
<SortableHeader column={column} title="State" />
),
cell: ({ row }) => row.original.State ?? "Unknown",
cell: ({ row }) => (
<StatusBadge
text={row.original.State}
intent={swarm_node_state_intention(row.original.State)}
/>
),
},
{
accessorKey: "Availability",
header: ({ column }) => (
<SortableHeader column={column} title="Availability" />
),
cell: ({ row }) => (
<StatusBadge
text={row.original.Availability}
intent={swarm_node_availability_intention(
row.original.Availability
)}
/>
),
},
{
accessorKey: "UpdatedAt",
@@ -104,17 +127,17 @@ export const SwarmNodes = ({
: "Unknown",
size: 200,
},
{
accessorKey: "CreatedAt",
header: ({ column }) => (
<SortableHeader column={column} title="Created" />
),
cell: ({ row }) =>
row.original.CreatedAt
? new Date(row.original.CreatedAt).toLocaleString()
: "Unknown",
size: 200,
},
// {
// accessorKey: "CreatedAt",
// header: ({ column }) => (
// <SortableHeader column={column} title="Created" />
// ),
// cell: ({ row }) =>
// row.original.CreatedAt
// ? new Date(row.original.CreatedAt).toLocaleString()
// : "Unknown",
// size: 200,
// },
]}
/>
</Section>
@@ -1,11 +1,26 @@
import { Section } from "@components/layouts";
import { useRead } from "@lib/hooks";
import { useExecute, usePermissions, useRead } from "@lib/hooks";
import { DataTable, SortableHeader } from "@ui/data-table";
import { Dispatch, ReactNode, SetStateAction } from "react";
import { Search } from "lucide-react";
import { Dispatch, ReactNode, SetStateAction, useMemo, useState } from "react";
import { PlusCircle, Search } from "lucide-react";
import { Input } from "@ui/input";
import { filterBySplit } from "@lib/utils";
import { cn, filterBySplit } from "@lib/utils";
import { SwarmResourceLink } from "..";
import { useToast } from "@ui/use-toast";
import { useNavigate } from "react-router-dom";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@ui/dialog";
import { Button } from "@ui/button";
import { Label } from "@ui/label";
import { language_from_path, MonacoEditor } from "@components/monaco";
import { Badge } from "@ui/badge";
export const SwarmSecrets = ({
id,
@@ -32,6 +47,7 @@ export const SwarmSecrets = ({
titleOther={titleOther}
actions={
<div className="flex items-center gap-4 flex-wrap">
<CreateSwarmSecret id={id} />
<div className="relative">
<Search className="w-4 absolute top-[50%] left-3 -translate-y-[50%] text-muted-foreground" />
<Input
@@ -58,8 +74,13 @@ export const SwarmSecrets = ({
<SwarmResourceLink
type="Secret"
swarm_id={id}
resource_id={row.original.ID}
resource_id={row.original.Name}
name={row.original.Name}
extra={
!row.original.InUse && (
<Badge variant="destructive">Unused</Badge>
)
}
/>
),
size: 200,
@@ -119,3 +140,80 @@ export const SwarmSecrets = ({
</Section>
);
};
const CreateSwarmSecret = ({ id }: { id: string }) => {
const { canExecute } = usePermissions({ type: "Swarm", id });
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
const [data, setData] = useState("");
const { toast } = useToast();
const nav = useNavigate();
const { mutate, isPending } = useExecute("CreateSwarmSecret", {
onSuccess: () => {
toast({ title: "Created swarm secret." });
setName("");
setData("");
setOpen(false);
nav(`/swarms/${id}/swarm-secret/${name}`);
},
});
const language = useMemo(() => language_from_path(name), [name]);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger>
<Button
className={cn("items-center gap-2", !canExecute && "hidden")}
variant="secondary"
disabled={isPending}
>
New Secret <PlusCircle className="w-4 h-4" />
</Button>
</DialogTrigger>
<DialogContent className="w-[90vw] max-w-[800px]">
<DialogHeader>
<DialogTitle>New Swarm Secret</DialogTitle>
<DialogDescription>
<p>Enter a unique name and data for the new Swarm Secret.</p>
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-6 py-8">
<div className="flex flex-col gap-2">
<Label htmlFor="secret-name">Name</Label>
<Input
id="secret-name"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full lg:w-[300px]"
autoFocus
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="secret-data">Data</Label>
<MonacoEditor
id="secret-data"
value={data}
onValueChange={setData}
language={language}
/>
</div>
<h2 className="font-extrabold">
Note: the data cannot be viewed again after creation.
</h2>
</div>
<DialogFooter>
<Button
variant="secondary"
onClick={() => {
mutate({ swarm: id, name, data });
}}
disabled={isPending}
>
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -6,6 +6,8 @@ import { Search } from "lucide-react";
import { Input } from "@ui/input";
import { filterBySplit } from "@lib/utils";
import { SwarmResourceLink } from "..";
import { StatusBadge } from "@components/util";
import { swarm_state_intention } from "@lib/color";
export const SwarmStacks = ({
id,
@@ -63,17 +65,23 @@ export const SwarmStacks = ({
/>
),
},
{
accessorKey: "Services",
header: ({ column }) => (
<SortableHeader column={column} title="Services" />
),
},
{
accessorKey: "State",
header: ({ column }) => (
<SortableHeader column={column} title="State" />
),
cell: ({ row }) => (
<StatusBadge
text={row.original.State}
intent={swarm_state_intention(row.original.State)}
/>
),
},
{
accessorKey: "Services",
header: ({ column }) => (
<SortableHeader column={column} title="Services" />
),
},
]}
/>
+66 -224
View File
@@ -5,10 +5,12 @@ import { SwarmComponents, SwarmResourceLink } from ".";
import { Types } from "komodo_client";
import { useRead, useSelectedResources } from "@lib/hooks";
import { Dispatch, ReactNode, SetStateAction } from "react";
import { filterBySplit } from "@lib/utils";
import { filterBySplit, filterMultitermBySplit } from "@lib/utils";
import { Section } from "@components/layouts";
import { Search } from "lucide-react";
import { Input } from "@ui/input";
import { StatusBadge } from "@components/util";
import { swarm_task_state_intention } from "@lib/color";
export const SwarmTable = ({ swarms }: { swarms: Types.SwarmListItem[] }) => {
const [_, setSelectedResources] = useSelectedResources("Swarm");
@@ -137,95 +139,30 @@ export const SwarmServicesTable = ({
);
};
export const SwarmStackServicesTable = ({
export const SwarmServiceTasksTable = ({
id,
services,
service_id,
titleOther,
_search,
}: {
id: string;
services: Types.SwarmStackServiceListItem[];
service_id: string | undefined;
titleOther: ReactNode;
_search: [string, Dispatch<SetStateAction<string>>];
}) => {
const [search, setSearch] = _search;
const filtered = filterBySplit(
services,
search,
(service) => service.Name ?? service.ID ?? "Unknown"
);
const tasks =
useRead(
"ListSwarmTasks",
{ swarm: id },
{ enabled: !!service_id }
).data?.filter((task) => service_id && task.ServiceID === service_id) ?? [];
return (
<Section
<SwarmTasksTable
id={id}
tasks={tasks}
titleOther={titleOther}
actions={
<div className="flex items-center gap-4 flex-wrap">
<div className="relative">
<Search className="w-4 absolute top-[50%] left-3 -translate-y-[50%] text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="search..."
className="pl-8 w-[200px] lg:w-[300px]"
/>
</div>
</div>
}
>
<DataTable
containerClassName="min-h-[60vh]"
tableKey="swarm-services"
data={filtered}
columns={[
{
accessorKey: "Name",
header: ({ column }) => (
<SortableHeader column={column} title="Name" />
),
cell: ({ row }) => (
<SwarmResourceLink
type="Service"
swarm_id={id}
resource_id={row.original.Name}
name={row.original.Name}
/>
),
size: 200,
},
{
accessorKey: "ID",
header: ({ column }) => (
<SortableHeader column={column} title="Id" />
),
size: 200,
},
{
accessorKey: "Image",
header: ({ column }) => (
<SortableHeader column={column} title="Image" />
),
size: 200,
},
{
accessorKey: "Mode",
header: ({ column }) => (
<SortableHeader column={column} title="Mode" />
),
},
{
accessorKey: "Replicas",
header: ({ column }) => (
<SortableHeader column={column} title="Replicas" />
),
},
{
accessorKey: "Ports",
header: ({ column }) => (
<SortableHeader column={column} title="Ports" />
),
},
]}
/>
</Section>
_search={_search}
/>
);
};
@@ -241,7 +178,6 @@ export const SwarmTasksTable = ({
_search: [string, Dispatch<SetStateAction<string>>];
}) => {
const [search, setSearch] = _search;
const nodes =
useRead("ListSwarmNodes", { swarm: id }, { refetchInterval: 10_000 })
.data ?? [];
@@ -256,12 +192,11 @@ export const SwarmTasksTable = ({
};
});
const filtered = filterBySplit(
tasks,
search,
(task) =>
task.Name ?? task.service?.Name ?? task.node?.Hostname ?? "Unknown"
);
const filtered = filterMultitermBySplit(tasks, search, (task) => [
task.ID,
task.node?.Hostname,
task.service?.Name,
]);
return (
<Section
@@ -298,6 +233,21 @@ export const SwarmTasksTable = ({
name={row.original.ID}
/>
),
size: 150,
},
{
accessorKey: "node.Hostname",
header: ({ column }) => (
<SortableHeader column={column} title="Node" />
),
cell: ({ row }) => (
<SwarmResourceLink
type="Node"
swarm_id={id}
resource_id={row.original.node?.ID}
name={row.original.node?.Hostname}
/>
),
size: 200,
},
{
@@ -315,32 +265,35 @@ export const SwarmTasksTable = ({
),
size: 200,
},
{
accessorKey: "node.Hostname",
header: ({ column }) => (
<SortableHeader column={column} title="Node" />
),
cell: ({ row }) => (
<SwarmResourceLink
type="Node"
swarm_id={id}
resource_id={row.original.node?.ID}
name={row.original.node?.Hostname}
/>
),
size: 200,
},
{
accessorKey: "State",
header: ({ column }) => (
<SortableHeader column={column} title="State" />
),
cell: ({ row }) => (
<StatusBadge
text={row.original.State}
intent={swarm_task_state_intention(
row.original.State,
row.original.DesiredState
)}
/>
),
},
{
accessorKey: "DesiredState",
header: ({ column }) => (
<SortableHeader column={column} title="Desired State" />
),
cell: ({ row }) => (
<StatusBadge
text={row.original.DesiredState}
intent={swarm_task_state_intention(
row.original.State,
row.original.DesiredState
)}
/>
),
},
{
accessorKey: "UpdatedAt",
@@ -353,128 +306,17 @@ export const SwarmTasksTable = ({
: "Unknown",
size: 200,
},
{
accessorKey: "CreatedAt",
header: ({ column }) => (
<SortableHeader column={column} title="Created" />
),
cell: ({ row }) =>
row.original.CreatedAt
? new Date(row.original.CreatedAt).toLocaleString()
: "Unknown",
size: 200,
},
]}
/>
</Section>
);
};
export const SwarmStackTasksTable = ({
id,
tasks: _tasks,
titleOther,
_search,
}: {
id: string;
tasks: Types.SwarmStackTaskListItem[];
titleOther: ReactNode;
_search: [string, Dispatch<SetStateAction<string>>];
}) => {
const [search, setSearch] = _search;
const nodes =
useRead("ListSwarmNodes", { swarm: id }, { refetchInterval: 10_000 })
.data ?? [];
const tasks = _tasks.map((task) => {
return {
...task,
node: nodes.find(
(node) =>
(task.Node ?? false) &&
(task.Node === node.ID ||
task.Node === node.Hostname ||
task.Node === node.Name)
),
};
});
const filtered = filterBySplit(
tasks,
search,
(task) => task.Name ?? task.node?.Hostname ?? "Unknown"
);
return (
<Section
titleOther={titleOther}
actions={
<div className="flex items-center gap-4 flex-wrap">
<div className="relative">
<Search className="w-4 absolute top-[50%] left-3 -translate-y-[50%] text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="search..."
className="pl-8 w-[200px] lg:w-[300px]"
/>
</div>
</div>
}
>
<DataTable
containerClassName="min-h-[60vh]"
tableKey="swarm-tasks"
data={filtered}
columns={[
{
accessorKey: "ID",
header: ({ column }) => (
<SortableHeader column={column} title="Id" />
),
cell: ({ row }) => (
<SwarmResourceLink
type="Task"
swarm_id={id}
resource_id={row.original.ID}
name={row.original.ID}
/>
),
size: 200,
},
{
accessorKey: "node.Hostname",
header: ({ column }) => (
<SortableHeader column={column} title="Node" />
),
cell: ({ row }) => (
<SwarmResourceLink
type="Node"
swarm_id={id}
resource_id={row.original.node?.ID}
name={row.original.node?.Hostname}
/>
),
size: 200,
},
{
accessorKey: "Image",
header: ({ column }) => (
<SortableHeader column={column} title="Image" />
),
},
{
accessorKey: "CurrentState",
header: ({ column }) => (
<SortableHeader column={column} title="State" />
),
},
{
accessorKey: "DesiredState",
header: ({ column }) => (
<SortableHeader column={column} title="Desired State" />
),
},
// {
// accessorKey: "CreatedAt",
// header: ({ column }) => (
// <SortableHeader column={column} title="Created" />
// ),
// cell: ({ row }) =>
// row.original.CreatedAt
// ? new Date(row.original.CreatedAt).toLocaleString()
// : "Unknown",
// size: 200,
// },
]}
/>
</Section>
@@ -1,13 +1,19 @@
import { useLocalStorage } from "@lib/hooks";
import { useMemo } from "react";
import { useLocalStorage, usePermissions, useRead, useUser } from "@lib/hooks";
import { ReactNode, useMemo } from "react";
import {
MobileFriendlyTabsSelector,
TabNoContent,
} from "@ui/mobile-friendly-tabs";
import { SwarmConfig } from "./config";
import { SwarmInfo } from "./info";
import { Section } from "@components/layouts";
import { ResourceComponents } from "..";
import { StackTable } from "../stack/table";
import { DeploymentTable } from "../deployment/table";
import { Types } from "komodo_client";
import { SwarmInspect } from "./info/inspect";
type SwarmTabsView = "Config" | "Info";
type SwarmTabsView = "Config" | "Info" | "Resources" | "Inspect";
export const SwarmTabs = ({ id }: { id: string }) => {
const [view, setView] = useLocalStorage<SwarmTabsView>(
@@ -15,7 +21,19 @@ export const SwarmTabs = ({ id }: { id: string }) => {
"Config"
);
// const swarm_info = useSwarm(id)?.info;
const { specificInspect } = usePermissions({ type: "Swarm", id });
const stacks =
useRead("ListStacks", {}).data?.filter(
(stack) => stack.info.swarm_id === id
) ?? [];
const noStacks = stacks.length === 0;
const deployments =
useRead("ListDeployments", {}).data?.filter(
(deployment) => deployment.info.swarm_id === id
) ?? [];
const noDeployments = deployments.length === 0;
const noResources = noDeployments && noStacks;
const tabs = useMemo<TabNoContent<SwarmTabsView>[]>(
() => [
@@ -25,6 +43,14 @@ export const SwarmTabs = ({ id }: { id: string }) => {
{
value: "Info",
},
{
value: "Inspect",
disabled: !specificInspect,
},
{
value: "Resources",
disabled: noResources,
},
],
[]
);
@@ -43,5 +69,57 @@ export const SwarmTabs = ({ id }: { id: string }) => {
return <SwarmConfig id={id} titleOther={Selector} />;
case "Info":
return <SwarmInfo id={id} titleOther={Selector} />;
case "Inspect":
return <SwarmInspect id={id} titleOther={Selector} />;
case "Resources":
return (
<SwarmTabsResources
id={id}
stacks={stacks}
deployments={deployments}
Selector={Selector}
/>
);
}
};
const SwarmTabsResources = ({
Selector,
id,
stacks,
deployments,
}: {
Selector: ReactNode;
id: string;
stacks: Types.StackListItem[];
deployments: Types.DeploymentListItem[];
}) => {
const is_admin = useUser().data?.admin ?? false;
const disable_non_admin_create =
useRead("GetCoreInfo", {}).data?.disable_non_admin_create ?? true;
return (
<Section titleOther={Selector}>
<Section
title="Stacks"
actions={
(is_admin || !disable_non_admin_create) && (
<ResourceComponents.Stack.New swarm_id={id} />
)
}
>
<StackTable stacks={stacks} />
</Section>
<Section
title="Deployments"
actions={
(is_admin || !disable_non_admin_create) && (
<ResourceComponents.Deployment.New swarm_id={id} />
)
}
>
<DeploymentTable deployments={deployments} />
</Section>
</Section>
);
};
+2 -2
View File
@@ -726,7 +726,7 @@ export const DockerResourceLink = ({
server_id: string;
name: string | undefined;
id?: string;
type: "container" | "network" | "image" | "volume";
type: DockerResourceType;
extra?: ReactNode;
muted?: boolean;
}) => {
@@ -745,7 +745,7 @@ export const DockerResourceLink = ({
<Icon server_id={server_id} name={type === "image" ? id : name} />
<div
title={name}
className="max-w-[250px] lg:max-w-[300px] overflow-hidden overflow-ellipsis break-words"
className="max-w-[250px] lg:max-w-[300px] overflow-hidden overflow-ellipsis break-words text-nowrap"
>
{name}
</div>
+81
View File
@@ -144,6 +144,87 @@ export const swarm_state_intention: (
}
};
export const swarm_node_state_intention: (
state?: Types.NodeState
) => ColorIntention = (state) => {
switch (state) {
case Types.NodeState.READY:
return "Good";
case Types.NodeState.DOWN:
return "Warning";
case Types.NodeState.DISCONNECTED:
return "Critical";
case Types.NodeState.UNKNOWN:
return "Neutral";
case undefined:
return "None";
}
};
export const swarm_node_availability_intention: (
state?: Types.NodeSpecAvailabilityEnum
) => ColorIntention = (state) => {
switch (state) {
case Types.NodeSpecAvailabilityEnum.ACTIVE:
return "Good";
case Types.NodeSpecAvailabilityEnum.DRAIN:
return "Warning";
case Types.NodeSpecAvailabilityEnum.PAUSE:
return "Critical";
case Types.NodeSpecAvailabilityEnum.EMPTY:
return "Neutral";
case undefined:
return "None";
}
};
export const swarm_node_role_intention: (
state?: Types.NodeSpecRoleEnum
) => ColorIntention = (state) => {
switch (state) {
case Types.NodeSpecRoleEnum.MANAGER:
return "Good";
case Types.NodeSpecRoleEnum.WORKER:
return "Neutral";
case Types.NodeSpecRoleEnum.EMPTY:
return "None";
case undefined:
return "None";
}
};
export const swarm_task_state_intention: (
state?: Types.TaskState,
desired?: Types.TaskState
) => ColorIntention = (state, desired) => {
// Case when its desired running
if (desired === Types.TaskState.RUNNING) {
if (state === Types.TaskState.RUNNING) {
return "Good";
} else {
return "Critical";
}
}
// Case when its desired shutdown
if (desired === Types.TaskState.SHUTDOWN) {
// If you want it shutdown, then running is critical.
if (state === Types.TaskState.RUNNING) {
return "Critical";
} else {
// Otherwise, it is "Down", give neutral color
return "Neutral";
}
}
// Others
if (state === desired) {
return "Good";
} else {
return "Critical";
}
};
export const server_state_intention: (
state?: Types.ServerState,
hasVersionMismatch?: boolean
+20
View File
@@ -241,6 +241,26 @@ export const filterBySplit = <T>(
);
};
export const filterMultitermBySplit = <T>(
items: T[] | undefined,
search: string,
extract: (item: T) => (string | undefined)[]
) => {
const split = search.toLowerCase().split(" ");
return (
(split.length
? items?.filter((item) => {
const target = extract(item)
.filter((str) => str)
.map((str) => str!.toLowerCase());
return split.every(
(term) => target.findIndex((t) => t.includes(term)) !== -1
);
})
: items) ?? []
);
};
export const sync_no_changes = (sync: Types.ResourceSync) => {
return (
(sync.info?.pending_deploy?.to_deploy ?? 0) === 0 &&
+33 -9
View File
@@ -18,7 +18,7 @@ import {
import { useRef, useState } from "react";
import { ThemeToggle } from "@ui/theme";
import { KOMODO_BASE_URL, sanitize_query } from "@main";
import { KeyRound, X } from "lucide-react";
import { KeyRound, Loader2, X } from "lucide-react";
import { cn, preparePasskeyCredential } from "@lib/utils";
import { Types } from "komodo_client";
import { useToast } from "@ui/use-toast";
@@ -37,8 +37,10 @@ const login_with_oauth = (provider: OauthProvider) => {
};
export default function Login({
passkeyIsPending: _passkeyIsPending,
totpIsPending: _totpIsPending,
}: {
passkeyIsPending?: boolean;
totpIsPending?: boolean;
}) {
const { toast } = useToast();
@@ -46,7 +48,11 @@ export default function Login({
const userInvalidate = useUserInvalidate();
const formRef = useRef<HTMLFormElement>(null);
const totpFormRef = useRef<HTMLFormElement>(null);
const [passkeyIsPending, setPasskeyPending] = useState(
_passkeyIsPending ?? false
);
const [totpIsPending, setTotpPending] = useState(_totpIsPending ?? false);
const secondFactorPending = passkeyIsPending || totpIsPending;
// If signing in another user, need to redirect away from /login manually
const maybeNavigate = location.pathname.startsWith("/login")
@@ -91,6 +97,7 @@ export default function Login({
case "Jwt":
return onSuccess(data);
case "Passkey":
setPasskeyPending(true);
return navigator.credentials
.get(preparePasskeyCredential(data))
.then((credential) => completePasskeyLogin({ credential }))
@@ -147,11 +154,11 @@ export default function Login({
completeTotpLogin({ code: creds.code });
};
const no_auth_configured =
const noAuthConfigured =
options !== undefined &&
Object.values(options).every((value) => value === false);
const show_sign_up = options !== undefined && !options.registration_disabled;
const showSignUp = options !== undefined && !options.registration_disabled;
// Otherwise just standard login
return (
@@ -174,7 +181,8 @@ export default function Login({
<CardDescription>Log In</CardDescription>
</div>
</div>
{!totpIsPending && (
{!secondFactorPending && (
<div className="flex gap-2">
{(
[
@@ -204,7 +212,7 @@ export default function Login({
</Button>
)
)}
{no_auth_configured && (
{noAuthConfigured && (
<Button variant="destructive" size="icon">
{" "}
<X className="w-4 h-4" />{" "}
@@ -213,7 +221,8 @@ export default function Login({
</div>
)}
</CardHeader>
{options?.local && !totpIsPending && (
{options?.local && !secondFactorPending && (
<form ref={formRef} onSubmit={handleSubmit} autoComplete="on">
<CardContent className="flex flex-col justify-center w-full gap-4">
<div className="flex flex-col gap-2">
@@ -238,7 +247,7 @@ export default function Login({
</div>
</CardContent>
<CardFooter className="flex gap-4 w-full justify-end">
{show_sign_up && (
{showSignUp && (
<Button
variant="outline"
type="button"
@@ -260,6 +269,17 @@ export default function Login({
</CardFooter>
</form>
)}
{passkeyIsPending && (
<CardContent className="flex flex-col justify-center items-center w-full gap-4">
<KeyRound className="w-16 h-16" />
<div className="flex items-center gap-4">
<Loader2 className="w-6 h-6 animate-spin" />
<h2>Provide your passkey to finish login</h2>
</div>
</CardContent>
)}
{totpIsPending && (
<form
ref={totpFormRef}
@@ -268,7 +288,10 @@ export default function Login({
>
<CardContent className="flex flex-col justify-center w-full gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="code">2FA Code</Label>
<Label htmlFor="code" className="flex items-center gap-2">
<KeyRound className="w-4 h-4" />
2FA Code{" "}
</Label>
<Input
id="code"
name="code"
@@ -291,7 +314,8 @@ export default function Login({
</CardFooter>
</form>
)}
{no_auth_configured && (
{noAuthConfigured && (
<CardContent className="w-full gap-2 text-muted-foreground text-sm">
No login methods have been configured. See the
<a
+162 -14
View File
@@ -1,9 +1,24 @@
import { ConfirmButton } from "@components/util";
import { useRead, useSetTitle, useUser, useWrite } from "@lib/hooks";
import { ConfirmButton, StatusBadge } from "@components/util";
import {
useLoginOptions,
useManageUser,
useRead,
useSetTitle,
useUser,
} from "@lib/hooks";
import { Button } from "@ui/button";
import { useToast } from "@ui/use-toast";
import { Loader2, User, Eye, EyeOff, KeyRound, UserPen } from "lucide-react";
import { useState } from "react";
import {
Loader2,
User,
Eye,
EyeOff,
KeyRound,
UserPen,
Trash,
CirclePlus,
} from "lucide-react";
import { useMemo, useState } from "react";
import { Input } from "@ui/input";
import { ApiKeysTable } from "@components/api-keys/table";
import { Section } from "@components/layouts";
@@ -12,6 +27,21 @@ import { Types } from "komodo_client";
import { CreateKey, DeleteKey } from "./api-key";
import { EnrollTotp } from "./totp";
import { EnrollPasskey } from "./passkey";
import { KOMODO_BASE_URL } from "@main";
import { DataTable } from "@ui/data-table";
import { Switch } from "@ui/switch";
import { Label } from "@ui/label";
import { cn } from "@lib/utils";
type LinkedLoginProvider = "Local" | "Github" | "Google" | "Oidc";
const useLinkWithOauth = () => {
const { mutateAsync } = useManageUser("BeginThirdPartyLoginLink");
return (provider: LinkedLoginProvider) =>
mutateAsync({}).then(() =>
location.replace(`${KOMODO_BASE_URL}/auth/${provider.toLowerCase()}/link`)
);
};
export const Profile = () => {
useSetTitle("Profile");
@@ -30,21 +60,67 @@ const ProfileInner = ({ user }: { user: Types.User }) => {
const { refetch: refetchUser } = useUser();
const { toast } = useToast();
const keys = useRead("ListApiKeys", {}).data ?? [];
const options = useLoginOptions().data;
const [username, setUsername] = useState(user.username);
const [password, setPassword] = useState("");
const [hidePassword, setHidePassword] = useState(true);
const { mutate: updateUsername } = useWrite("UpdateUserUsername", {
const { mutate: updateUsername } = useManageUser("UpdateUsername", {
onSuccess: () => {
toast({ title: "Username updated." });
refetchUser();
},
});
const { mutate: updatePassword } = useWrite("UpdateUserPassword", {
const { mutate: updatePassword } = useManageUser("UpdatePassword", {
onSuccess: () => {
toast({ title: "Password updated." });
setPassword("");
},
});
const { mutate: update_skip_2fa } = useManageUser("UpdateThirdPartySkip2fa", {
onSuccess: () => {
toast({ title: "Updated user third party skip 2fa." });
refetchUser();
},
});
const { mutate: unlink } = useManageUser("UnlinkLogin", {
onSuccess: () => {
toast({ title: "Unlinked login." });
refetchUser();
},
});
const thirdPartyProviders: Array<{
provider: LinkedLoginProvider;
enabled: boolean;
linked: boolean;
}> = useMemo(
() =>
[
{
provider: "Local" as LinkedLoginProvider,
enabled: !!options?.local,
linked: !!user?.linked_logins?.Local,
},
{
provider: "Google" as LinkedLoginProvider,
enabled: !!options?.google,
linked: !!user?.linked_logins?.Google,
},
{
provider: "Github" as LinkedLoginProvider,
enabled: !!options?.github,
linked: !!user?.linked_logins?.Github,
},
{
provider: "Oidc" as LinkedLoginProvider,
enabled: !!options?.oidc,
linked: !!user?.linked_logins?.Oidc,
},
].filter(
({ enabled, provider }) => enabled && user.config.type !== provider
),
[user, options]
);
const linkWithOauth = useLinkWithOauth();
return (
<div className="flex flex-col gap-6">
{/* Profile */}
@@ -55,7 +131,7 @@ const ProfileInner = ({ user }: { user: Types.User }) => {
<UserProfile user={user} />
{/* Update Username */}
<div className="flex items-center gap-4">
<div className="flex items-center gap-4 flex-wrap">
<div className="text-muted-foreground font-mono">Username:</div>
<div className="w-[200px] lg:w-[300px]">
<Input
@@ -73,8 +149,8 @@ const ProfileInner = ({ user }: { user: Types.User }) => {
</div>
{/* Update Password */}
{user.config.type === "Local" && (
<div className="flex items-center gap-4">
{options?.local && (
<div className="flex items-center gap-4 flex-wrap">
<div className="text-muted-foreground font-mono">Password:</div>
<div className="w-[200px] lg:w-[300px] flex items-center gap-2">
<Input
@@ -107,15 +183,87 @@ const ProfileInner = ({ user }: { user: Types.User }) => {
</Card>
</Section>
{/* Linked Logins */}
{!!thirdPartyProviders.length && (
<Section title="Linked Logins" icon={<KeyRound className="w-4 h-4" />}>
<DataTable
tableKey="linked-logins-v1"
data={thirdPartyProviders}
columns={[
{ header: "Provider", accessorKey: "provider" },
{
header: "Linked",
cell: ({
row: {
original: { linked },
},
}) => (
<StatusBadge
text={linked ? "Linked" : "Unlinked"}
intent={linked ? "Good" : "Critical"}
/>
),
},
{
header: "Link",
cell: ({
row: {
original: { provider, linked },
},
}) =>
linked ? (
<ConfirmButton
title={`Unlink ${provider}`}
icon={<Trash className="w-4 h-4" />}
variant="destructive"
onClick={() => unlink({ provider })}
/>
) : provider === "Local" ? (
<>Set password above to enable.</>
) : (
<Button
variant="outline"
className="flex gap-2 px-3 items-center"
onClick={() => linkWithOauth(provider)}
disabled={!!user.linked_logins?.[provider]}
>
Link {provider}
<CirclePlus className="w-4 h-4" />
</Button>
),
},
]}
/>
</Section>
)}
{/* 2FA */}
<Section title="2FA" icon={<KeyRound className="w-4 h-4" />}>
<div className="flex items-center gap-4">
<div className="flex items-center gap-x-6 gap-y-4 flex-wrap">
<EnrollPasskey user={user} />
<EnrollTotp user={user} />
{/* <StatusBadge
text={user.totp?.confirmed_at ? "Enrolled" : "Not Enrolled"}
intent={user.totp?.confirmed_at ? "Good" : "Critical"}
/> */}
<div
className={cn(
"flex items-center gap-2",
!user.passkey?.created_at && !user.totp?.confirmed_at && "hidden"
)}
>
<Switch
id="update-skip-2fa"
checked={user.third_party_skip_2fa}
onCheckedChange={(skip) => update_skip_2fa({ skip })}
/>
<Label
htmlFor="update-skip-2fa"
className="flex items-center gap-2 cursor-pointer"
>
Skip 2FA for third party logins
<StatusBadge
text={user.third_party_skip_2fa ? "ENABLED" : "DISABLED"}
intent={user.third_party_skip_2fa ? "Good" : "Critical"}
/>
</Label>
</div>
</div>
</Section>
+24 -2
View File
@@ -133,11 +133,33 @@ const StackServicePageInner = ({
<SwarmResourceLink
type="Service"
swarm_id={stack.info.swarm_id}
resource_id={swarm_service.ID}
resource_id={swarm_service.Name}
name={swarm_service.Name}
/>
</>
)}
{swarm_service?.Configs.map((config) => (
<Fragment key={config}>
|
<SwarmResourceLink
type="Config"
swarm_id={stack.info.swarm_id}
resource_id={config}
name={config}
/>
</Fragment>
))}
{swarm_service?.Secrets.map((secret) => (
<Fragment key={secret}>
|
<SwarmResourceLink
type="Secret"
swarm_id={stack.info.swarm_id}
resource_id={secret}
name={secret}
/>
</Fragment>
))}
</>
)}
{/* SERVER ONLY */}
@@ -217,7 +239,7 @@ const StackServicePageInner = ({
<div className="mt-8 flex flex-col gap-12">
{/* Actions */}
{canExecute && (
{canExecute && !stack?.info.swarm_id && (
<Section title="Execute (Service)" icon={<Zap className="w-4 h-4" />}>
<div className="flex gap-4 items-center flex-wrap">
{Object.entries(Actions).map(([key, Action]) => (
+40 -3
View File
@@ -6,11 +6,12 @@ import {
TabNoContent,
} from "@ui/mobile-friendly-tabs";
import { Types } from "komodo_client";
import { useMemo } from "react";
import { ReactNode, useMemo, useState } from "react";
import { StackServiceInspect } from "./inspect";
import { StackServiceLogs } from "./log";
import { SwarmServiceTasksTable } from "@components/resources/swarm/table";
type StackServiceTabsView = "Log" | "Inspect" | "Terminals";
type StackServiceTabsView = "Tasks" | "Log" | "Inspect" | "Terminals";
export const StackServiceTabs = ({
stack,
@@ -36,13 +37,17 @@ export const StackServiceTabs = ({
const container_terminals_disabled =
useServer(stack.info.server_id)?.info.container_terminals_disabled ?? false;
const logDisabled = !specificLogs || down;
const inspectDisabled = !specificInspect || down;
const terminalDisabled =
!specificTerminal ||
container_terminals_disabled ||
container?.state !== Types.ContainerStateStatusEnum.Running;
const view =
(!stack.info.swarm_id && _view === "Tasks") ||
(inspectDisabled && _view === "Inspect") ||
(terminalDisabled && _view === "Terminals")
? "Log"
@@ -50,6 +55,10 @@ export const StackServiceTabs = ({
const tabs = useMemo<TabNoContent[]>(
() => [
{
value: "Tasks",
hidden: !swarm_service,
},
{
value: "Log",
disabled: logDisabled,
@@ -64,7 +73,7 @@ export const StackServiceTabs = ({
hidden: !container,
},
],
[logDisabled, inspectDisabled, terminalDisabled]
[!swarm_service, logDisabled, inspectDisabled, terminalDisabled]
);
const Selector = (
@@ -88,6 +97,14 @@ export const StackServiceTabs = ({
);
switch (view) {
case "Tasks":
return (
<StackServiceTasksTable
stack={stack}
swarm_service={swarm_service}
Selector={Selector}
/>
);
case "Log":
return (
<StackServiceLogs
@@ -110,3 +127,23 @@ export const StackServiceTabs = ({
return <ContainerTerminals target={target} titleOther={Selector} />;
}
};
const StackServiceTasksTable = ({
stack,
swarm_service,
Selector,
}: {
stack: Types.StackListItem;
swarm_service: Types.SwarmServiceListItem | undefined;
Selector: ReactNode;
}) => {
const _search = useState("");
return (
<SwarmServiceTasksTable
id={stack.info.swarm_id}
service_id={swarm_service?.ID}
titleOther={Selector}
_search={_search}
/>
);
};
+281 -53
View File
@@ -1,13 +1,31 @@
import { ResourceLink } from "@components/resources/common";
import { PageHeaderName } from "@components/util";
import { usePermissions, useRead, useSetTitle } from "@lib/hooks";
import {
ResourceDescription,
ResourceLink,
ResourcePageHeader,
} from "@components/resources/common";
import {
useExecute,
useLocalStorage,
usePermissions,
useRead,
useSetTitle,
} from "@lib/hooks";
import { Button } from "@ui/button";
import { ChevronLeft, Loader2, Zap } from "lucide-react";
import { useNavigate, useParams } from "react-router-dom";
import { MonacoEditor } from "@components/monaco";
import { ChevronLeft, History, Loader2 } from "lucide-react";
import { Link, useParams } from "react-router-dom";
import { language_from_path, MonacoEditor } from "@components/monaco";
import { SWARM_ICONS, useSwarm } from "@components/resources/swarm";
import { Section } from "@components/layouts";
import { ExportButton } from "@components/export";
import { stroke_color_class_by_intention } from "@lib/color";
import { ResourceNotifications } from "@pages/resource-notifications";
import { Types } from "komodo_client";
import { ReactNode, useMemo } from "react";
import { MobileFriendlyTabsSelector } from "@ui/mobile-friendly-tabs";
import { RemoveSwarmResource } from "./remove";
import { ConfirmUpdate } from "@components/config/util";
import { useToast } from "@ui/use-toast";
import { Badge } from "@ui/badge";
export default function SwarmConfigPage() {
const { id, config: __config } = useParams() as {
@@ -16,19 +34,18 @@ export default function SwarmConfigPage() {
};
const _config = decodeURIComponent(__config);
const swarm = useSwarm(id);
const { data, isPending } = useRead("InspectSwarmConfig", {
const inUse = useRead("ListSwarmConfigs", { swarm: id }).data?.find(
(config) => config.ID === _config || config.Name === _config
)?.InUse;
const { data: config, isPending } = useRead("InspectSwarmConfig", {
swarm: id,
config: _config,
});
const config = data?.[0];
useSetTitle(
`${swarm?.name} | Config | ${config?.Spec?.Name ?? config?.ID ?? "Unknown"}`
);
const { canExecute } = usePermissions({
const { canWrite } = usePermissions({
type: "Swarm",
id,
});
const nav = useNavigate();
useSetTitle(`${swarm?.name} | Config | ${config?.Spec?.Name ?? "Unknown"}`);
if (isPending) {
return (
@@ -43,55 +60,266 @@ export default function SwarmConfigPage() {
}
const Icon = SWARM_ICONS.Config;
const intent = inUse ? "Good" : "Critical";
return (
<div className="flex flex-col gap-16 mb-24">
{/* HEADER */}
<div className="flex flex-col gap-4">
{/* BACK */}
<div className="flex items-center justify-between mb-4">
<Button
className="gap-2"
variant="secondary"
onClick={() => nav("/swarms/" + id)}
>
<ChevronLeft className="w-4" /> Back
<div>
<div className="w-full flex items-center justify-between mb-12">
<Link to={"/swarms/" + id}>
<Button className="gap-2" variant="secondary">
<ChevronLeft className="w-4" />
Back
</Button>
</div>
{/* TITLE */}
</Link>
<div className="flex items-center gap-4">
<div className="mt-1">
<Icon size={8} />
</div>
<PageHeaderName name={config?.Spec?.Name ?? config?.ID} />
</div>
{/* INFO */}
<div className="flex flex-wrap gap-4 items-center text-muted-foreground">
Swarm Config
<ResourceLink type="Swarm" id={id} />
<ExportButton targets={[{ type: "Swarm", id }]} />
</div>
</div>
{canExecute && config.ID && (
<Section title="Execute" icon={<Zap className="w-4 h-4" />}>
<div className="flex gap-4 items-center flex-wrap">
<RemoveSwarmResource
id={id}
type="Config"
resource_id={config.ID}
resource_name={config.Spec?.Name}
<div className="flex flex-col xl:flex-row gap-4">
{/* HEADER */}
<div className="w-full flex flex-col gap-4">
<div className="flex flex-col gap-2 border rounded-md">
<ResourcePageHeader
type={undefined}
id={undefined}
intent={intent}
icon={
<Icon
size={8}
className={stroke_color_class_by_intention(intent)}
/>
}
resource={undefined}
name={config.Spec?.Name}
state={!inUse && <Badge variant="destructive">Unused</Badge>}
status={undefined}
/>
<div className="flex flex-col pb-2 px-4">
<div className="flex items-center gap-x-4 gap-y-0 flex-wrap text-muted-foreground">
<div>Swarm Config</div>
|
<ResourceLink type="Swarm" id={id} />
</div>
</div>
</div>
</Section>
)}
<ResourceDescription type="Swarm" id={id} disabled={!canWrite} />
</div>
{/** NOTIFICATIONS */}
<ResourceNotifications type="Swarm" id={id} />
</div>
<MonacoEditor
value={JSON.stringify(config, null, 2)}
language="json"
readOnly
/>
<div className="mt-8 flex flex-col gap-12">
{/* Actions */}
{config.ID && (
<RemoveSwarmResource
id={id}
type="Config"
resource_id={config.ID}
resource_name={config.Spec?.Name}
/>
)}
{/* Tabs */}
<div className="pt-4">
{swarm && <SwarmConfigTabs swarm={swarm} config={_config} />}
</div>
</div>
</div>
);
}
type SwarmConfigTabsView = "Edit" | "Inspect";
const SwarmConfigTabs = ({
swarm,
config,
}: {
swarm: Types.SwarmListItem;
config: string;
}) => {
const [_view, setView] = useLocalStorage<SwarmConfigTabsView>(
`swarm-${swarm.id}-config-${config}-tabs-v2`,
"Edit"
);
const { specificInspect } = usePermissions({
type: "Swarm",
id: swarm.id,
});
const view = !specificInspect && _view === "Inspect" ? "Edit" : _view;
const tabs = useMemo(
() => [
{
value: "Edit",
},
{
value: "Inspect",
disabled: !specificInspect,
},
],
[specificInspect]
);
const Selector = (
<MobileFriendlyTabsSelector
tabs={tabs}
value={view}
onValueChange={setView as any}
tabsTriggerClassname="w-[110px]"
/>
);
switch (view) {
case "Edit":
return (
<SwarmConfigEdit
swarm={swarm.id}
config={config}
titleOther={Selector}
/>
);
case "Inspect":
return (
<SwarmConfigInspect
swarm={swarm.id}
config={config}
titleOther={Selector}
/>
);
}
};
const SwarmConfigEdit = ({
swarm,
config,
titleOther,
}: {
swarm: string;
config: string;
titleOther: ReactNode;
}) => {
const {
data: inspect,
isPending,
refetch,
} = useRead("InspectSwarmConfig", {
swarm,
config,
});
const { canExecute } = usePermissions({
type: "Swarm",
id: swarm,
});
const { toast } = useToast();
const { mutate: sendEdit } = useExecute("RotateSwarmConfig", {
onSuccess: (res) => {
toast({
title: res.success ? "Config updated." : "Failed to update config.",
variant: res.success ? undefined : "destructive",
});
setEdit({ edit: undefined });
refetch();
},
});
const [{ edit }, setEdit] = useLocalStorage<{ edit: string | undefined }>(
`swarm-${swarm}-config-${config}-edit-v2`,
{ edit: undefined }
);
if (isPending) {
return (
<div className="flex justify-center w-full py-4">
<Loader2 className="w-8 h-8 animate-spin" />
</div>
);
}
if (!config) {
return <div className="flex w-full py-4">Failed to inspect config.</div>;
}
const name = inspect?.Spec?.Name;
const data = inspect?.Spec?.Data;
const language = name ? language_from_path(name) : undefined;
return (
<Section
titleOther={titleOther}
actions={
canExecute && (
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={(e) => {
e.stopPropagation();
setEdit({ edit: undefined });
}}
className="flex items-center gap-2"
disabled={edit === undefined}
>
<History className="w-4 h-4" />
Reset
</Button>
<span onClick={(e) => e.stopPropagation()}>
<ConfirmUpdate
previous={{ contents: data }}
content={{ contents: edit }}
onConfirm={async () => {
name && edit && sendEdit({ swarm, config: name, data: edit });
}}
disabled={edit === undefined}
language={language}
loading={isPending}
/>
</span>
</div>
)
}
>
<MonacoEditor
value={edit ?? data ?? "Failed to retrieve config data"}
language={language}
onValueChange={(edit) => setEdit({ edit })}
readOnly={!canExecute}
/>
</Section>
);
};
const SwarmConfigInspect = ({
swarm,
config,
titleOther,
}: {
swarm: string;
config: string;
titleOther: ReactNode;
}) => {
const { data: inspect, isPending } = useRead("InspectSwarmConfig", {
swarm,
config,
});
if (isPending) {
return (
<div className="flex justify-center w-full py-4">
<Loader2 className="w-8 h-8 animate-spin" />
</div>
);
}
if (!config) {
return <div className="flex w-full py-4">Failed to inspect config.</div>;
}
return (
<Section titleOther={titleOther}>
<MonacoEditor
value={JSON.stringify(inspect, null, 2)}
language="json"
readOnly
/>
</Section>
);
};
+4 -1
View File
@@ -23,12 +23,15 @@ export const RemoveSwarmResource = ({
}) => {
const nav = useNavigate();
const { mutate: remove, isPending } = useExecute(`RemoveSwarm${type}s`, {
onSuccess: () => nav("/swarms/" + id),
onSuccess: () => {
nav("/swarms/" + id);
},
});
let key = `${type.toLowerCase()}s`;
return (
<ActionWithDialog
name={resource_name ?? resource_id}
variant="destructive"
title="Remove"
icon={<Trash className="h-4 w-4" />}
onClick={() =>
+280 -52
View File
@@ -1,13 +1,31 @@
import { ResourceLink } from "@components/resources/common";
import { PageHeaderName } from "@components/util";
import { usePermissions, useRead, useSetTitle } from "@lib/hooks";
import {
ResourceDescription,
ResourceLink,
ResourcePageHeader,
} from "@components/resources/common";
import {
useExecute,
useLocalStorage,
usePermissions,
useRead,
useSetTitle,
} from "@lib/hooks";
import { Button } from "@ui/button";
import { ChevronLeft, Loader2, Zap } from "lucide-react";
import { useNavigate, useParams } from "react-router-dom";
import { MonacoEditor } from "@components/monaco";
import { ChevronLeft, History, Loader2 } from "lucide-react";
import { Link, useParams } from "react-router-dom";
import { language_from_path, MonacoEditor } from "@components/monaco";
import { SWARM_ICONS, useSwarm } from "@components/resources/swarm";
import { RemoveSwarmResource } from "./remove";
import { Section } from "@components/layouts";
import { ExportButton } from "@components/export";
import { stroke_color_class_by_intention } from "@lib/color";
import { ResourceNotifications } from "@pages/resource-notifications";
import { Types } from "komodo_client";
import { ReactNode, useMemo } from "react";
import { MobileFriendlyTabsSelector } from "@ui/mobile-friendly-tabs";
import { RemoveSwarmResource } from "./remove";
import { ConfirmUpdate } from "@components/config/util";
import { useToast } from "@ui/use-toast";
import { Badge } from "@ui/badge";
export default function SwarmSecretPage() {
const { id, secret: __secret } = useParams() as {
@@ -16,18 +34,18 @@ export default function SwarmSecretPage() {
};
const _secret = decodeURIComponent(__secret);
const swarm = useSwarm(id);
const inUse = useRead("ListSwarmSecrets", { swarm: id }).data?.find(
(secret) => secret.ID === _secret || secret.Name === _secret
)?.InUse;
const { data: secret, isPending } = useRead("InspectSwarmSecret", {
swarm: id,
secret: _secret,
});
useSetTitle(
`${swarm?.name} | Secret | ${secret?.Spec?.Name ?? secret?.ID ?? "Unknown"}`
);
const { canExecute } = usePermissions({
const { canWrite } = usePermissions({
type: "Swarm",
id,
});
const nav = useNavigate();
useSetTitle(`${swarm?.name} | Secret | ${secret?.Spec?.Name ?? "Unknown"}`);
if (isPending) {
return (
@@ -42,55 +60,265 @@ export default function SwarmSecretPage() {
}
const Icon = SWARM_ICONS.Secret;
const intent = inUse ? "Good" : "Critical";
return (
<div className="flex flex-col gap-16 mb-24">
{/* HEADER */}
<div className="flex flex-col gap-4">
{/* BACK */}
<div className="flex items-center justify-between mb-4">
<Button
className="gap-2"
variant="secondary"
onClick={() => nav("/swarms/" + id)}
>
<ChevronLeft className="w-4" /> Back
<div>
<div className="w-full flex items-center justify-between mb-12">
<Link to={"/swarms/" + id}>
<Button className="gap-2" variant="secondary">
<ChevronLeft className="w-4" />
Back
</Button>
</div>
{/* TITLE */}
</Link>
<div className="flex items-center gap-4">
<div className="mt-1">
<Icon size={8} />
</div>
<PageHeaderName name={secret?.Spec?.Name ?? secret?.ID} />
</div>
{/* INFO */}
<div className="flex flex-wrap gap-4 items-center text-muted-foreground">
Swarm Secret
<ResourceLink type="Swarm" id={id} />
<ExportButton targets={[{ type: "Swarm", id }]} />
</div>
</div>
{canExecute && secret.ID && (
<Section title="Execute" icon={<Zap className="w-4 h-4" />}>
<div className="flex gap-4 items-center flex-wrap">
<RemoveSwarmResource
id={id}
type="Secret"
resource_id={secret.ID}
resource_name={secret.Spec?.Name}
<div className="flex flex-col xl:flex-row gap-4">
{/* HEADER */}
<div className="w-full flex flex-col gap-4">
<div className="flex flex-col gap-2 border rounded-md">
<ResourcePageHeader
type={undefined}
id={undefined}
intent={intent}
icon={
<Icon
size={8}
className={stroke_color_class_by_intention(intent)}
/>
}
resource={undefined}
name={secret.Spec?.Name}
state={!inUse && <Badge variant="destructive">Unused</Badge>}
status={undefined}
/>
<div className="flex flex-col pb-2 px-4">
<div className="flex items-center gap-x-4 gap-y-0 flex-wrap text-muted-foreground">
<div>Swarm Secret</div>
|
<ResourceLink type="Swarm" id={id} />
</div>
</div>
</div>
</Section>
)}
<ResourceDescription type="Swarm" id={id} disabled={!canWrite} />
</div>
{/** NOTIFICATIONS */}
<ResourceNotifications type="Swarm" id={id} />
</div>
<MonacoEditor
value={JSON.stringify(secret, null, 2)}
language="json"
readOnly
/>
<div className="mt-8 flex flex-col gap-12">
{/* Actions */}
{secret.ID && (
<RemoveSwarmResource
id={id}
type="Secret"
resource_id={secret.ID}
resource_name={secret.Spec?.Name}
/>
)}
{/* Tabs */}
<div className="pt-4">
{swarm && <SwarmSecretTabs swarm={swarm} secret={_secret} />}
</div>
</div>
</div>
);
}
type SwarmSecretTabsView = "Edit" | "Inspect";
const SwarmSecretTabs = ({
swarm,
secret,
}: {
swarm: Types.SwarmListItem;
secret: string;
}) => {
const [_view, setView] = useLocalStorage<SwarmSecretTabsView>(
`swarm-${swarm.id}-secret-${secret}-tabs-v2`,
"Edit"
);
const { specificInspect } = usePermissions({
type: "Swarm",
id: swarm.id,
});
const view = !specificInspect && _view === "Inspect" ? "Edit" : _view;
const tabs = useMemo(
() => [
{
value: "Edit",
},
{
value: "Inspect",
disabled: !specificInspect,
},
],
[specificInspect]
);
const Selector = (
<MobileFriendlyTabsSelector
tabs={tabs}
value={view}
onValueChange={setView as any}
tabsTriggerClassname="w-[110px]"
/>
);
switch (view) {
case "Edit":
return (
<SwarmSecretEdit
swarm={swarm.id}
secret={secret}
titleOther={Selector}
/>
);
case "Inspect":
return (
<SwarmSecretInspect
swarm={swarm.id}
secret={secret}
titleOther={Selector}
/>
);
}
};
const SwarmSecretEdit = ({
swarm,
secret,
titleOther,
}: {
swarm: string;
secret: string;
titleOther: ReactNode;
}) => {
const {
data: inspect,
isPending,
refetch,
} = useRead("InspectSwarmSecret", {
swarm,
secret,
});
const { canExecute } = usePermissions({
type: "Swarm",
id: swarm,
});
const { toast } = useToast();
const { mutate: sendEdit } = useExecute("RotateSwarmSecret", {
onSuccess: (res) => {
toast({
title: res.success ? "Secret updated." : "Failed to update secret.",
variant: res.success ? undefined : "destructive",
});
setEdit({ edit: undefined });
refetch();
},
});
const [{ edit }, setEdit] = useLocalStorage<{ edit: string | undefined }>(
`swarm-${swarm}-secret-${secret}-edit`,
{ edit: undefined }
);
if (isPending) {
return (
<div className="flex justify-center w-full py-4">
<Loader2 className="w-8 h-8 animate-spin" />
</div>
);
}
if (!secret) {
return <div className="flex w-full py-4">Failed to inspect secret.</div>;
}
const name = inspect?.Spec?.Name;
const language = name ? language_from_path(name) : undefined;
return (
<Section
titleOther={titleOther}
actions={
canExecute && (
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={(e) => {
e.stopPropagation();
setEdit({ edit: undefined });
}}
className="flex items-center gap-2"
disabled={edit === undefined}
>
<History className="w-4 h-4" />
Reset
</Button>
<span onClick={(e) => e.stopPropagation()}>
<ConfirmUpdate
previous={{ contents: "" }}
content={{ contents: edit }}
onConfirm={async () => {
name && edit && sendEdit({ swarm, secret: name, data: edit });
}}
disabled={edit === undefined}
language={language}
loading={isPending}
/>
</span>
</div>
)
}
>
<MonacoEditor
value={edit ?? "# Enter new secret and save to rotate"}
language={language}
onValueChange={(edit) => setEdit({ edit })}
readOnly={!canExecute}
/>
</Section>
);
};
const SwarmSecretInspect = ({
swarm,
secret,
titleOther,
}: {
swarm: string;
secret: string;
titleOther: ReactNode;
}) => {
const { data: inspect, isPending } = useRead("InspectSwarmSecret", {
swarm,
secret,
});
if (isPending) {
return (
<div className="flex justify-center w-full py-4">
<Loader2 className="w-8 h-8 animate-spin" />
</div>
);
}
if (!secret) {
return <div className="flex w-full py-4">Failed to inspect secret.</div>;
}
return (
<Section titleOther={titleOther}>
<MonacoEditor
value={JSON.stringify(inspect, null, 2)}
language="json"
readOnly
/>
</Section>
);
};
+62 -20
View File
@@ -13,7 +13,11 @@ import { Button } from "@ui/button";
import { ChevronLeft, Loader2, Zap } from "lucide-react";
import { Link, useParams } from "react-router-dom";
import { MonacoEditor } from "@components/monaco";
import { SWARM_ICONS, useSwarm } from "@components/resources/swarm";
import {
SWARM_ICONS,
SwarmResourceLink,
useSwarm,
} from "@components/resources/swarm";
import { ExportButton } from "@components/export";
import { Types } from "komodo_client";
import {
@@ -21,11 +25,12 @@ import {
swarm_state_intention,
} from "@lib/color";
import { ResourceNotifications } from "@pages/resource-notifications";
import { ReactNode, useMemo } from "react";
import { Fragment, ReactNode, useMemo, useState } from "react";
import { MobileFriendlyTabsSelector } from "@ui/mobile-friendly-tabs";
import { SwarmServiceLogs } from "./log";
import { Section } from "@components/layouts";
import { RemoveSwarmResource } from "./remove";
import { SwarmServiceTasksTable } from "@components/resources/swarm/table";
export default function SwarmServicePage() {
const { id, service: __service } = useParams() as {
@@ -70,9 +75,10 @@ export default function SwarmServicePage() {
}
const Icon = SWARM_ICONS.Service;
const state = get_service_state_from_tasks(tasks);
const state = service.State;
const intention = swarm_state_intention(state);
const strokeColor = stroke_color_class_by_intention(intention);
const service_id = service.ID;
return (
<div>
@@ -103,9 +109,31 @@ export default function SwarmServicePage() {
/>
<div className="flex flex-col pb-2 px-4">
<div className="flex items-center gap-x-4 gap-y-0 flex-wrap text-muted-foreground">
<ResourceLink type="Swarm" id={id} />
<div>|</div>
<div>Swarm Service</div>
|
<ResourceLink type="Swarm" id={id} />
{service?.Configs.map((config) => (
<Fragment key={config}>
|
<SwarmResourceLink
type="Config"
swarm_id={id}
resource_id={config}
name={config}
/>
</Fragment>
))}
{service?.Secrets.map((secret) => (
<Fragment key={secret}>
|
<SwarmResourceLink
type="Secret"
swarm_id={id}
resource_id={secret}
name={secret}
/>
</Fragment>
))}
</div>
</div>
</div>
@@ -131,35 +159,51 @@ export default function SwarmServicePage() {
{/* Tabs */}
<div className="pt-4">
{swarm && <SwarmServiceTabs swarm={swarm} service={_service} />}
{swarm && (
<SwarmServiceTabs
swarm={swarm}
service={_service}
service_id={service_id}
/>
)}
</div>
</div>
</div>
);
}
type SwarmServiceTabsView = "Log" | "Inspect";
type SwarmServiceTabsView = "Tasks" | "Log" | "Inspect";
const SwarmServiceTabs = ({
swarm,
service,
service_id,
}: {
swarm: Types.SwarmListItem;
service: string;
service_id: string | undefined;
}) => {
const _search = useState("");
const [_view, setView] = useLocalStorage<SwarmServiceTabsView>(
`swarm-${swarm.id}-service-${service}-tabs-v2`,
"Log"
"Tasks"
);
const { specificLogs, specificInspect } = usePermissions({
type: "Swarm",
id: swarm.id,
});
const view = !specificInspect && _view === "Inspect" ? "Log" : _view;
const view =
(!specificLogs && _view === "Log") ||
(!specificInspect && _view === "Inspect")
? "Tasks"
: _view;
const tabs = useMemo(
() => [
{
value: "Tasks",
},
{
value: "Log",
disabled: !specificLogs,
@@ -182,6 +226,15 @@ const SwarmServiceTabs = ({
);
switch (view) {
case "Tasks":
return (
<SwarmServiceTasksTable
id={swarm.id}
service_id={service_id}
titleOther={Selector}
_search={_search}
/>
);
case "Log":
return (
<SwarmServiceLogs
@@ -238,14 +291,3 @@ const SwarmServiceInspect = ({
</Section>
);
};
const get_service_state_from_tasks = (
tasks: Types.SwarmTaskListItem[]
): Types.SwarmState => {
for (const task of tasks) {
if (task.State !== task.DesiredState) {
return Types.SwarmState.Unhealthy;
}
}
return Types.SwarmState.Healthy;
};
+4 -4
View File
@@ -25,8 +25,8 @@ import { MobileFriendlyTabsSelector } from "@ui/mobile-friendly-tabs";
import { Section } from "@components/layouts";
import { MonacoEditor } from "@components/monaco";
import {
SwarmStackServicesTable,
SwarmStackTasksTable,
SwarmServicesTable,
SwarmTasksTable,
} from "@components/resources/swarm/table";
import {
Select,
@@ -194,7 +194,7 @@ const SwarmStackTabs = ({
switch (view) {
case "Services":
return (
<SwarmStackServicesTable
<SwarmServicesTable
id={swarm.id}
services={stack.Services}
titleOther={Selector}
@@ -203,7 +203,7 @@ const SwarmStackTabs = ({
);
case "Tasks":
return (
<SwarmStackTasksTable
<SwarmTasksTable
id={swarm.id}
tasks={stack.Tasks}
titleOther={Selector}
+35 -9
View File
@@ -13,13 +13,17 @@ import { Button } from "@ui/button";
import { ChevronLeft, Loader2 } from "lucide-react";
import { Link, useParams } from "react-router-dom";
import { MonacoEditor } from "@components/monaco";
import { SWARM_ICONS, SwarmResourceLink, useSwarm } from "@components/resources/swarm";
import {
SWARM_ICONS,
SwarmResourceLink,
useSwarm,
} from "@components/resources/swarm";
import { Types } from "komodo_client";
import {
stroke_color_class_by_intention,
swarm_state_intention,
} from "@lib/color";
import { ReactNode, useMemo } from "react";
import { Fragment, ReactNode, useMemo } from "react";
import { MobileFriendlyTabsSelector } from "@ui/mobile-friendly-tabs";
import { SwarmServiceLogs } from "./log";
import { Section } from "@components/layouts";
@@ -107,19 +111,41 @@ export default function SwarmTaskPage() {
|
<ResourceLink type="Swarm" id={id} />
|
<SwarmResourceLink
type="Service"
swarm_id={id}
resource_id={service?.ID}
name={service?.Name}
/>
|
<SwarmResourceLink
type="Node"
swarm_id={id}
resource_id={node?.ID}
name={node?.Hostname}
/>
|
<SwarmResourceLink
type="Service"
swarm_id={id}
resource_id={service?.ID}
name={service?.Name}
/>
{task?.Configs.map((config) => (
<Fragment key={config}>
|
<SwarmResourceLink
type="Config"
swarm_id={id}
resource_id={config}
name={config}
/>
</Fragment>
))}
{task?.Secrets.map((secret) => (
<Fragment key={secret}>
|
<SwarmResourceLink
type="Secret"
swarm_id={id}
resource_id={secret}
name={secret}
/>
</Fragment>
))}
</div>
</div>
</div>
+10 -14
View File
@@ -47,18 +47,15 @@ let jwt_redeem_sent = false;
let passkey_sent = false;
/// returns whether to show login / loading screen depending on state of exchange token loop
const useQueryState = () => {
const useAuthState = () => {
const { toast } = useToast();
const onSuccess = ({ user_id, jwt }: Types.JwtResponse) => {
LOGIN_TOKENS.add_and_change(user_id, jwt);
sanitize_query_inner(search);
};
const { mutate: redeemJwt, isPending: redeem_pending } = useAuth(
"ExchangeForJwt",
{
onSuccess,
}
);
const { mutate: redeemJwt } = useAuth("ExchangeForJwt", {
onSuccess,
});
const { mutate: completePasskeyLogin } = useAuth("CompletePasskeyLogin", {
onSuccess,
});
@@ -95,7 +92,6 @@ const useQueryState = () => {
return {
jwt_redeem_ready,
redeem_pending,
passkey_pending: !!passkey,
totp: search.get("totp") === "true",
};
@@ -103,13 +99,9 @@ const useQueryState = () => {
export const Router = () => {
// Handle exchange token loop to avoid showing login flash
const { redeem_pending, passkey_pending, totp } = useQueryState();
const { jwt_redeem_ready, passkey_pending, totp } = useAuthState();
if (totp) {
return <Login totpIsPending />;
}
if (redeem_pending || passkey_pending) {
if (jwt_redeem_ready) {
return (
<div className="w-screen h-screen flex justify-center items-center">
<Loader2 className="w-8 h-8 animate-spin" />
@@ -117,6 +109,10 @@ export const Router = () => {
);
}
if (passkey_pending || totp) {
return <Login passkeyIsPending={passkey_pending} totpIsPending={totp} />;
}
return (
<Suspense
fallback={
+33 -16
View File
@@ -28,10 +28,11 @@ use komodo_client::entities::{
};
use mongo_indexed::{create_index, create_unique_index};
use mungos::{
by_id::update_one_by_id,
init::MongoBuilder,
mongodb::{
Collection, Database,
bson::{doc, oid::ObjectId},
bson::{doc, oid::ObjectId, to_bson},
},
};
@@ -118,28 +119,44 @@ impl Client {
user: &User,
password: &str,
) -> anyhow::Result<()> {
let UserConfig::Local { .. } = user.config else {
return Err(anyhow!(
"User is not a 'Local' (username / password) user"
));
};
if password.is_empty() {
return Err(anyhow!("Password cannot be empty."));
}
let id = ObjectId::from_str(&user.id)
.context("User id not valid ObjectId.")?;
let hashed_password =
hash_password(password).context("Failed to hash password")?;
self
.users
.update_one(
doc! { "_id": id },
doc! { "$set": {
"config.data.password": hashed_password
} },
)
//
let update = match user.config {
UserConfig::Service { .. } => {
return Err(anyhow!(
"Service Users cannot add additional login methods"
));
}
// Update a primary 'Local' user's password directly.
UserConfig::Local { .. } => {
doc! {
"$set": {
"config.data.password": hashed_password
}
}
}
// Update User with Local password as an entry in 'additional_logins'
_ => {
let bson = to_bson(&UserConfig::Local {
password: hashed_password,
})
.context("Failed to serialize login method to bson")?;
doc! {
"$set": {
"linked_logins.Local": bson
}
}
}
};
update_one_by_id(&self.users, &user.id, update, None)
.await
.context("Failed to update user password on database.")?;
Ok(())
}