From 3565f2a860f00fbfaa7cd5168ebcfab82dfc2335 Mon Sep 17 00:00:00 2001 From: Reda Tarzalt Date: Wed, 13 May 2026 13:31:48 -0400 Subject: [PATCH 01/18] add files for auth --- .env.example | 7 + .gitignore | 1 + Cargo.lock | 206 +++++++++++++- fleet/harmony-fleet-operator/Cargo.toml | 8 + .../src/frontend/auth.rs | 267 ++++++++++++++++++ .../src/frontend/mod.rs | 1 + .../src/frontend/server.rs | 257 ++++++++++++++++- .../src/frontend/views/badges.rs | 32 +++ .../src/frontend/views/deployments.rs | 65 ++++- .../src/frontend/views/devices.rs | 77 ++++- .../src/frontend/views/mod.rs | 1 + fleet/harmony-fleet-operator/src/main.rs | 2 + 12 files changed, 890 insertions(+), 34 deletions(-) create mode 100644 .env.example create mode 100644 fleet/harmony-fleet-operator/src/frontend/auth.rs create mode 100644 fleet/harmony-fleet-operator/src/frontend/views/badges.rs diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..2f4ae554 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +FLEET_AUTH_ISSUER_URL= +FLEET_AUTH_AUTHORIZE_URL= +FLEET_AUTH_TOKEN_URL= +FLEET_AUTH_CLIENT_ID= +FLEET_AUTH_REDIRECT_URI= +FLEET_AUTH_SCOPE= +FLEET_AUTH_TRUSTED_AUDIENCES= diff --git a/.gitignore b/.gitignore index 76ea8ec2..cce78a00 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ ### General ### private_repos/ +.env ### Harmony ### harmony.log diff --git a/Cargo.lock b/Cargo.lock index b627afeb..ea60426a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1010,6 +1010,58 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes 1.11.1", + "form_urlencoded", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.8.1", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper 1.0.2", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes 1.11.1", + "futures-core", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper 1.0.2", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "backon" version = "1.6.0" @@ -1739,6 +1791,17 @@ dependencies = [ "version_check", ] +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + [[package]] name = "cookie_store" version = "0.20.0" @@ -3992,22 +4055,34 @@ version = "0.1.0" dependencies = [ "anyhow", "async-nats", + "async-trait", + "axum", + "base64 0.22.1", "chrono", "clap", + "dotenvy", "futures-util", "harmony", "harmony-fleet-auth", "harmony-reconciler-contracts", "k8s-openapi", "kube", + "maud", + "openidconnect", + "rand 0.9.2", + "reqwest 0.12.28", "schemars 0.8.22", "serde", "serde_json", + "sha2", "thiserror 2.0.18", "tokio", + "tokio-stream", "toml", + "tower-cookies", "tracing", "tracing-subscriber", + "url", ] [[package]] @@ -5111,6 +5186,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.13.0" @@ -5582,6 +5666,36 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "maud" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8156733e27020ea5c684db5beac5d1d611e1272ab17901a49466294b84fc217e" +dependencies = [ + "axum-core", + "http 1.4.0", + "itoa", + "maud_macros", +] + +[[package]] +name = "maud_macros" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7261b00f3952f617899bc012e3dbd56e4f0110a038175929fa5d18e5a19913ca" +dependencies = [ + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.117", +] + [[package]] name = "md-5" version = "0.10.6" @@ -5870,6 +5984,26 @@ dependencies = [ "libc", ] +[[package]] +name = "oauth2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" +dependencies = [ + "base64 0.22.1", + "chrono", + "getrandom 0.2.17", + "http 1.4.0", + "rand 0.8.5", + "reqwest 0.12.28", + "serde", + "serde_json", + "serde_path_to_error", + "sha2", + "thiserror 1.0.69", + "url", +] + [[package]] name = "objc2" version = "0.6.4" @@ -5962,6 +6096,37 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "openidconnect" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c6709ba2ea764bbed26bce1adf3c10517113ddea6f2d4196e4851757ef2b2" +dependencies = [ + "base64 0.21.7", + "chrono", + "dyn-clone", + "ed25519-dalek", + "hmac", + "http 1.4.0", + "itertools 0.10.5", + "log", + "oauth2", + "p256 0.13.2", + "p384", + "rand 0.8.5", + "rsa", + "serde", + "serde-value", + "serde_json", + "serde_path_to_error", + "serde_plain", + "serde_with", + "sha2", + "subtle", + "thiserror 1.0.69", + "url", +] + [[package]] name = "openssl-probe" version = "0.1.6" @@ -6615,6 +6780,18 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "version_check", +] + [[package]] name = "psl-types" version = "2.0.11" @@ -6817,7 +6994,7 @@ dependencies = [ "crossterm 0.28.1", "indoc", "instability", - "itertools", + "itertools 0.13.0", "lru", "paste", "strum 0.26.3", @@ -7718,6 +7895,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_plain" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50" +dependencies = [ + "serde", +] + [[package]] name = "serde_repr" version = "0.1.20" @@ -8860,6 +9046,22 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower-cookies" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "151b5a3e3c45df17466454bb74e9ecedecc955269bdedbf4d150dfa393b55a36" +dependencies = [ + "axum-core", + "cookie 0.18.1", + "futures-util", + "http 1.4.0", + "parking_lot", + "pin-project-lite", + "tower-layer", + "tower-service", +] + [[package]] name = "tower-http" version = "0.6.8" @@ -9070,7 +9272,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" dependencies = [ - "itertools", + "itertools 0.13.0", "unicode-segmentation", "unicode-width 0.1.14", ] diff --git a/fleet/harmony-fleet-operator/Cargo.toml b/fleet/harmony-fleet-operator/Cargo.toml index d4ef4d28..054d524b 100644 --- a/fleet/harmony-fleet-operator/Cargo.toml +++ b/fleet/harmony-fleet-operator/Cargo.toml @@ -33,7 +33,15 @@ clap.workspace = true futures-util = { workspace = true } thiserror.workspace = true async-trait.workspace = true +url.workspace = true +base64.workspace = true +rand.workspace = true +sha2 = "0.10" +reqwest.workspace = true axum = { version = "0.8", optional = true } maud = { version = "0.27", features = ["axum"], optional = true } +openidconnect = { version = "4", default-features = false, features = ["reqwest", "rustls-tls"] } tokio-stream = { version = "0.1", optional = true } +tower-cookies = "0.11" +dotenvy = "0.15" diff --git a/fleet/harmony-fleet-operator/src/frontend/auth.rs b/fleet/harmony-fleet-operator/src/frontend/auth.rs new file mode 100644 index 00000000..4a1fe33f --- /dev/null +++ b/fleet/harmony-fleet-operator/src/frontend/auth.rs @@ -0,0 +1,267 @@ +use std::env; +use std::str::FromStr; + +use anyhow::Result; +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use openidconnect::core::{CoreClient, CoreIdToken, CoreProviderMetadata}; +use openidconnect::reqwest; +use openidconnect::Nonce; +use openidconnect::{ + ClientId, IssuerUrl, +}; +use chrono::{Duration, Utc}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use url::Url; +use rand::random; + +const AUTHORIZE_URL_ENV: &str = "FLEET_AUTH_AUTHORIZE_URL"; +const CLIENT_ID_ENV: &str = "FLEET_AUTH_CLIENT_ID"; +const REDIRECT_URI_ENV: &str = "FLEET_AUTH_REDIRECT_URI"; +const SCOPE_ENV: &str = "FLEET_AUTH_SCOPE"; +const TOKEN_URL_ENV: &str = "FLEET_AUTH_TOKEN_URL"; +const ISSUER_URL_ENV: &str = "FLEET_AUTH_ISSUER_URL"; +const TRUSTED_AUDIENCES_ENV: &str = "FLEET_AUTH_TRUSTED_AUDIENCES"; + +#[derive(Debug, Clone)] +pub struct ValidatedUser { + pub subject: String, + pub email: Option, + pub name: Option, +} + +#[derive(Debug, Deserialize)] +pub struct TokenResponse { + pub access_token: String, + pub id_token: String, + pub token_type: String, + pub expires_in: Option, +} + +#[derive(Debug, Clone)] +pub struct LoginAttempt { + pub authorize_url: String, + pub state: String, + pub pkce_code_verifier: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LoginAttemptCookie { + pub state: String, + pub pkce_code_verifier: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DashboardSession { + pub subject: String, + pub email: Option, + pub name: Option, + pub expires_at: i64, +} + +#[derive(Debug, Deserialize)] +pub struct RawAuthCallbackQuery { + pub code: Option, + pub state: Option, + pub error: Option, + pub error_description: Option, +} + +#[derive(Debug)] +pub enum AuthCallbackQuery { + Success { + code: String, + state: String, + }, + Failure { + error: String, + error_description: Option, + state: Option, + }, +} + +impl From<&LoginAttempt> for LoginAttemptCookie { + fn from(attempt: &LoginAttempt) -> Self { + Self { + state: attempt.state.clone(), + pkce_code_verifier: attempt.pkce_code_verifier.clone(), + } + } +} + +impl TryFrom for AuthCallbackQuery { + type Error = anyhow::Error; + + fn try_from(raw: RawAuthCallbackQuery) -> Result { + match raw { + RawAuthCallbackQuery { + code: Some(code), + state: Some(state), + error: None, + error_description: None, + } => Ok(Self::Success { code, state }), + + RawAuthCallbackQuery { + code: None, + state, + error: Some(error), + error_description, + } => Ok(Self::Failure { + error, + error_description, + state, + }), + + _ => Err(anyhow::anyhow!("invalid auth callback query shape")), + } + } +} + +pub fn build_login_attempt() -> Result { + let state = random_url_token(32); + let pkce_code_verifier = random_url_token(32); + let code_challenge = pkce_s256_challenge(&pkce_code_verifier); + + let authorize_url = required_env(AUTHORIZE_URL_ENV)?; + let client_id = required_env(CLIENT_ID_ENV)?; + let redirect_uri = required_env(REDIRECT_URI_ENV)?; + let scope = required_env(SCOPE_ENV)?; + + let mut url = Url::parse(&authorize_url)?; + url.query_pairs_mut() + .append_pair("client_id", &client_id) + .append_pair("redirect_uri", &redirect_uri) + .append_pair("response_type", "code") + .append_pair("scope", &scope) + .append_pair("code_challenge", &code_challenge) + .append_pair("code_challenge_method", "S256") + .append_pair("state", &state); + + Ok(LoginAttempt { + authorize_url: url.into(), + state, + pkce_code_verifier, + }) +} + +pub fn build_dashboard_session(user: &ValidatedUser) -> DashboardSession { + DashboardSession { + subject: user.subject.clone(), + email: user.email.clone(), + name: user.name.clone(), + expires_at: (Utc::now() + Duration::hours(8)).timestamp(), + } +} + +pub fn encode_dashboard_session(session: &DashboardSession) -> anyhow::Result { + Ok(URL_SAFE_NO_PAD.encode(serde_json::to_vec(session)?)) +} + +pub fn decode_dashboard_session(value: &str) -> anyhow::Result { + let decoded = URL_SAFE_NO_PAD + .decode(value) + .map_err(|e| anyhow::anyhow!("invalid dashboard session cookie encoding: {e}"))?; + + let session: DashboardSession = serde_json::from_slice(&decoded) + .map_err(|e| anyhow::anyhow!("invalid dashboard session cookie payload: {e}"))?; + + if session.expires_at <= chrono::Utc::now().timestamp() { + anyhow::bail!("dashboard session expired"); + } + + Ok(session) + } + +pub async fn validate_id_token( + id_token: &str, + http_client: &reqwest::Client, +) -> anyhow::Result { + let issuer_url = required_env(ISSUER_URL_ENV)?; + let client_id = required_env(CLIENT_ID_ENV)?; + let trusted_audiences = trusted_audiences()?; + + let provider_metadata = + CoreProviderMetadata::discover_async(IssuerUrl::new(issuer_url)?, http_client).await?; + + let client = CoreClient::from_provider_metadata( + provider_metadata, + ClientId::new(client_id), + None, + ); + + let id_token = CoreIdToken::from_str(id_token)?; + let verifier = client + .id_token_verifier() + .set_other_audience_verifier_fn(move |aud| trusted_audiences.contains(&aud.to_string())); + let claims = id_token.claims(&verifier, |_: Option<&Nonce>| Ok(()))?; + let subject = claims.subject().to_string(); + let email = claims.email().map(|email| email.to_string()); + let name = claims + .name() + .and_then(|localized| localized.get(None)) + .map(|name| name.to_string()); + + Ok(ValidatedUser { + subject, + email, + name, + }) +} + +pub async fn exchange_code_for_token( + client: &reqwest::Client, + pkce_code_verifier: &str, + code: &str, +) -> anyhow::Result { + let token_url = required_env(TOKEN_URL_ENV)?; + let redirect_uri = required_env(REDIRECT_URI_ENV)?; + let client_id = required_env(CLIENT_ID_ENV)?; + + let response = client + .post(token_url) + .form(&[ + ("grant_type", "authorization_code"), + ("code", code), + ("redirect_uri", &redirect_uri), + ("client_id", &client_id), + ("code_verifier", pkce_code_verifier), + ]) + .send() + .await?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + anyhow::bail!("failed to exchange code for token: {status} {body}"); + } + + Ok(response.json::().await?) +} + +fn required_env(name: &str) -> anyhow::Result { + env::var(name).map_err(|_| anyhow::anyhow!("missing required environment variable {name}")) +} + +fn trusted_audiences() -> anyhow::Result> { + Ok(required_env(TRUSTED_AUDIENCES_ENV)? + .split(',') + .map(str::trim) + .filter(|aud| !aud.is_empty()) + .map(ToOwned::to_owned) + .collect()) +} + +fn random_url_token(byte_len: usize) -> String { + let mut bytes = vec![0u8; byte_len]; + for chunk in bytes.chunks_mut(32) { + let random_bytes: [u8; 32] = random(); + chunk.copy_from_slice(&random_bytes[..chunk.len()]); + } + URL_SAFE_NO_PAD.encode(bytes) +} + +fn pkce_s256_challenge(code_verifier: &str) -> String { + let digest = Sha256::digest(code_verifier.as_bytes()); + URL_SAFE_NO_PAD.encode(digest) +} diff --git a/fleet/harmony-fleet-operator/src/frontend/mod.rs b/fleet/harmony-fleet-operator/src/frontend/mod.rs index c14d8059..3735b677 100644 --- a/fleet/harmony-fleet-operator/src/frontend/mod.rs +++ b/fleet/harmony-fleet-operator/src/frontend/mod.rs @@ -9,6 +9,7 @@ //! future CLI. pub mod assets; +pub mod auth; pub mod layout; pub mod server; pub mod views; diff --git a/fleet/harmony-fleet-operator/src/frontend/server.rs b/fleet/harmony-fleet-operator/src/frontend/server.rs index 588404d3..c83bef7d 100644 --- a/fleet/harmony-fleet-operator/src/frontend/server.rs +++ b/fleet/harmony-fleet-operator/src/frontend/server.rs @@ -7,22 +7,34 @@ use std::time::Duration; use anyhow::Result; use axum::Router; use axum::body::Body; -use axum::extract::{Path, State}; +use axum::extract::{Path, Query, State}; use axum::http::{StatusCode, header}; +use axum::response::{ Redirect, IntoResponse, Response }; use axum::response::sse::{Event, KeepAlive, Sse}; -use axum::response::{IntoResponse, Response}; +use axum::middleware::{self, Next}; +use axum::http::Request; use axum::routing::{get, post}; +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; use maud::Markup; use tokio_stream::StreamExt; +use tokio_stream::wrappers::IntervalStream; +use tower_cookies::cookie::{Cookie, SameSite}; +use tower_cookies::{CookieManagerLayer, Cookies}; use super::assets::{HTMX_JS, HTMX_SSE_JS, TAILWIND_CSS}; +use super::auth::LoginAttemptCookie; use super::layout::page; use super::views::{dashboard, deployments as deployments_view, devices as devices_view}; +use crate::frontend::auth::{ exchange_code_for_token, validate_id_token, build_dashboard_session, encode_dashboard_session }; +use crate::frontend::auth::{self, AuthCallbackQuery, RawAuthCallbackQuery}; use crate::service::FleetService; /// Default high port — keeps clear of NATS (4222), k8s API (6443), /// and common metrics/webhook ports (8080/9090/9443). pub const DEFAULT_PORT: u16 = 18080; +const LOGIN_ATTEMPT_COOKIE: &str = "harmony_fleet_login_attempt"; +const DASHBOARD_SESSION_COOKIE: &str = "harmony_fleet_session"; #[derive(Clone)] pub struct AppState { @@ -56,20 +68,207 @@ impl Config { } pub fn router(state: AppState) -> Router { - let mut r = Router::new() + + let public_routes = Router::new() + .route("/login", get(login_handler)) + .route("/logout", get(logout_handler)) + .route("/auth/callback", get(auth_callback_handler)) + .route("/static/tailwind.css", get(tailwind_css)) + .route("/static/htmx.min.js", get(htmx_js)) + .route("/static/htmx-ext-sse.js", get(htmx_sse_js)); + + let private_routes = Router::new() .route("/", get(dashboard_handler)) .route("/devices", get(devices_handler)) .route("/devices/{id}/blacklist", post(blacklist_handler)) .route("/deployments", get(deployments_handler)) - .route("/static/tailwind.css", get(tailwind_css)) - .route("/static/htmx.min.js", get(htmx_js)) - .route("/static/htmx-ext-sse.js", get(htmx_sse_js)); + .route("/deployment/{id}", get(deployment_handler)) + .route("/devices/{id}/logs", get(device_logs_handler)) + .route("/devices/{id}/logs/stream", get(device_logs_stream_handler)) + .route_layer(middleware::from_fn(require_auth)); + + let mut r = public_routes.merge(private_routes); if state.live_reload { r = r.route("/__dev/reload", get(dev_reload_sse)); } - r.with_state(state) + r.layer(CookieManagerLayer::new()).with_state(state) +} + +async fn require_auth(cookies: Cookies, mut req: Request, next: Next) -> Response { + let Some(cookie) = cookies.get(DASHBOARD_SESSION_COOKIE) else { + return unauthenticated_response(&req); + }; + + match auth::decode_dashboard_session(cookie.value()) { + Ok(session) => { + req.extensions_mut().insert(session); + next.run(req).await + } + Err(e) => { + tracing::warn!(%e, "invalid dashboard session"); + cookies.remove(Cookie::from(DASHBOARD_SESSION_COOKIE)); + unauthenticated_response(&req) + } + } +} + +fn unauthenticated_response(req: &Request) -> Response { + if is_sse_request(req) { + return (StatusCode::UNAUTHORIZED, "authentication required").into_response(); + } + + if is_htmx_request(req) { + return Response::builder() + .status(StatusCode::UNAUTHORIZED) + .header("HX-Redirect", "/login") + .body(Body::empty()) + .expect("well-formed HTMX auth response"); + } + + Redirect::to("/login").into_response() +} + +fn is_htmx_request(req: &Request) -> bool { + req.headers() + .get("HX-Request") + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value == "true") +} + +fn is_sse_request(req: &Request) -> bool { + req.headers() + .get(header::ACCEPT) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.contains("text/event-stream")) +} + +async fn logout_handler(cookies: Cookies) -> Redirect { + cookies.remove(Cookie::from(DASHBOARD_SESSION_COOKIE)); + Redirect::to("/login") +} + +async fn login_handler(cookies: Cookies) -> Result { + let attempt = auth::build_login_attempt()?; + let cookie_payload = LoginAttemptCookie::from(&attempt); + let cookie_value = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&cookie_payload)?); + + cookies.add( + Cookie::build((LOGIN_ATTEMPT_COOKIE, cookie_value)) + .http_only(true) + .same_site(SameSite::Lax) + .path("/") + .build(), + ); + + tracing::debug!(state = %attempt.state, "created OIDC login attempt"); + tracing::debug!( + verifier_len = attempt.pkce_code_verifier.len(), + "PKCE code verifier stored for callback validation" + ); + + Ok(Redirect::temporary(&attempt.authorize_url)) +} + +async fn auth_callback_handler( + cookies: Cookies, + Query(raw): Query, +) -> Result { + let query = AuthCallbackQuery::try_from(raw); + + match query { + Ok(AuthCallbackQuery::Success { code, state }) => { + let attempt = read_login_attempt_cookie(&cookies)?; + + validate_callback_state(&cookies, &attempt, &state)?; + + let client = reqwest::Client::new(); + let tokens = + exchange_code_for_token(&client, &attempt.pkce_code_verifier, &code).await?; + let user = validate_id_token(&tokens.id_token, &client).await?; + let session = build_dashboard_session(&user); + let session_cookie = encode_dashboard_session(&session)?; + + cookies.add( + Cookie::build((DASHBOARD_SESSION_COOKIE, session_cookie)) + .http_only(true) + .same_site(SameSite::Lax) + .path("/") + .build(), + ); + + + tracing::debug!( + subject = %user.subject, + email = ?user.email, + name = ?user.name, + "ID token validated" + ); + + Ok(Redirect::to("/").into_response()) + } + + Ok(AuthCallbackQuery::Failure { + error, + error_description, + state, + }) => { + tracing::warn!( + %error, + ?error_description, + ?state, + "SSO callback returned an error" + ); + + Ok(maud::html! { + pre { + "SSO login failed\n" + "Error: " (error) "\n" + "Description: " (error_description.unwrap_or_default()) "\n" + } + }.into_response()) + } + + Err(e) => { + tracing::warn!(%e, "SSO callback query shape mismatch"); + + Ok(maud::html! { + pre { + "SSO login failed\n" + "Error: " (e.to_string()) "\n" + } + }.into_response()) + } + } +} + +fn validate_callback_state( + cookies: &Cookies, + attempt: &LoginAttemptCookie, + returned_state: &str, +) -> Result<(), AppError> { + cookies.remove(Cookie::from(LOGIN_ATTEMPT_COOKIE)); // always clear the cookie + if attempt.state != returned_state { + return Err(anyhow::anyhow!("auth callback state mismatch; start again at /login").into()); + } + + Ok(()) +} + +fn read_login_attempt_cookie(cookies: &Cookies) -> Result { + let attempt_cookie = cookies + .get("harmony_fleet_login_attempt") + .ok_or_else(|| anyhow::anyhow!("missing login attempt cookie; start again at /login"))?; + + let decoded = URL_SAFE_NO_PAD + .decode(attempt_cookie.value()) + .map_err(|e| anyhow::anyhow!("invalid login attempt cookie encoding: {e}"))?; + + let attempt: LoginAttemptCookie = serde_json::from_slice(&decoded) + .map_err(|e| anyhow::anyhow!("invalid login attempt cookie payload: {e}"))?; + + Ok(attempt) } pub async fn run(cfg: Config) -> Result<()> { @@ -80,6 +279,27 @@ pub async fn run(cfg: Config) -> Result<()> { Ok(()) } +async fn device_logs_handler(Path(id): Path) -> Result { + Ok(devices_view::logs_modal(&id)) +} + +async fn device_logs_stream_handler( + Path(id): Path, +) -> Sse>> { + let mut line_no = 0usize; + let stream = IntervalStream::new(tokio::time::interval(Duration::from_secs(1))).map(move |_| { + line_no += 1; + let now = chrono::Utc::now().format("%H:%M:%S"); + let html = format!( + r#"
{now}{id} mock log line #{line_no}
"#, + ); + + Ok::<_, Infallible>(Event::default().event("log").data(html)) + }); + + Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15))) +} + // ---- handlers: each is a 3-liner: extract state, call service, render. ---- async fn dashboard_handler(State(s): State) -> Result { @@ -101,6 +321,29 @@ async fn deployments_handler(State(s): State) -> Result, + Path(id): Path, +) -> Result { + let deployments = s.fleet.list_deployments().await?; + let deployment = deployments + .iter() + .find(|d| d.name == id) + .ok_or_else(|| anyhow::anyhow!("deployment not found: {id}"))?; + + let devices = s.fleet.list_devices().await?; + let deployment_devices: Vec<_> = devices + .into_iter() + .filter(|device| device.deployment.as_deref() == Some(id.as_str())) + .collect(); + + Ok(page( + "Deployment", + s.live_reload, + deployments_view::detail(deployment, &deployment_devices), + )) +} + async fn blacklist_handler( State(s): State, Path(id): Path, diff --git a/fleet/harmony-fleet-operator/src/frontend/views/badges.rs b/fleet/harmony-fleet-operator/src/frontend/views/badges.rs new file mode 100644 index 00000000..f66a45ac --- /dev/null +++ b/fleet/harmony-fleet-operator/src/frontend/views/badges.rs @@ -0,0 +1,32 @@ +use maud::{Markup, html}; + +use crate::service::{DeploymentStatus, DeviceStatus}; + +pub fn device_status(s: DeviceStatus) -> Markup { + let (label, classes) = match s { + DeviceStatus::Healthy => ("healthy", "bg-emerald-900 text-emerald-300"), + DeviceStatus::Pending => ("pending", "bg-amber-900 text-amber-300"), + DeviceStatus::Stale => ("stale", "bg-rose-900 text-rose-300"), + DeviceStatus::Blacklisted => ("blacklisted", "bg-slate-800 text-slate-400"), + DeviceStatus::Unknown => ("unknown", "bg-slate-800 text-slate-500"), + }; + status_badge(label, classes) +} + +pub fn deployment_status(s: DeploymentStatus) -> Markup { + let (label, classes) = match s { + DeploymentStatus::Active => ("active", "bg-emerald-900 text-emerald-300"), + DeploymentStatus::Rolling => ("rolling", "bg-sky-900 text-sky-300"), + DeploymentStatus::Failing => ("failing", "bg-rose-900 text-rose-300"), + DeploymentStatus::Paused => ("paused", "bg-slate-800 text-slate-400"), + }; + status_badge(label, classes) +} + +fn status_badge(label: &str, classes: &str) -> Markup { + html! { + span class={"inline-block rounded px-2 py-0.5 text-xs font-medium " (classes)} { + (label) + } + } +} diff --git a/fleet/harmony-fleet-operator/src/frontend/views/deployments.rs b/fleet/harmony-fleet-operator/src/frontend/views/deployments.rs index cd13d4cb..0f128235 100644 --- a/fleet/harmony-fleet-operator/src/frontend/views/deployments.rs +++ b/fleet/harmony-fleet-operator/src/frontend/views/deployments.rs @@ -1,6 +1,7 @@ use maud::{Markup, html}; -use crate::service::{DeploymentStatus, DeploymentSummary}; +use crate::frontend::views::badges; +use crate::service::{DeploymentSummary, DeviceSummary}; pub fn page(deployments: &[DeploymentSummary]) -> Markup { html! { @@ -21,8 +22,15 @@ pub fn page(deployments: &[DeploymentSummary]) -> Markup { tbody class="divide-y divide-slate-800 bg-slate-950" { @for d in deployments { tr { - td class="px-3 py-2 font-mono text-slate-200" { (d.name) } - td class="px-3 py-2" { (status_badge(d.status)) } + td class="px-3 py-2 font-mono" { + a + href={"/deployment/" (d.name)} + class="text-slate-200 hover:text-orange-400 hover:underline" + { + (d.name) + } + } + td class="px-3 py-2" { (badges::deployment_status(d.status)) } td class="px-3 py-2 text-slate-300" { (d.healthy_devices) " / " (d.target_devices) " healthy" } @@ -35,16 +43,49 @@ pub fn page(deployments: &[DeploymentSummary]) -> Markup { } } -fn status_badge(s: DeploymentStatus) -> Markup { - let (label, classes) = match s { - DeploymentStatus::Active => ("active", "bg-emerald-900 text-emerald-300"), - DeploymentStatus::Rolling => ("rolling", "bg-sky-900 text-sky-300"), - DeploymentStatus::Failing => ("failing", "bg-rose-900 text-rose-300"), - DeploymentStatus::Paused => ("paused", "bg-slate-800 text-slate-400"), - }; +pub fn detail(deployment: &DeploymentSummary, devices: &[DeviceSummary]) -> Markup { html! { - span class={"inline-block rounded px-2 py-0.5 text-xs font-medium " (classes)} { - (label) + section { + div class="mb-4" { + a href="/deployments" class="text-sm text-slate-500 hover:text-slate-300" { "←" } + } + + div class="flex items-baseline gap-3 mb-4" { + h2 class="text-lg font-medium text-slate-300" { "Deployment" } + span class="font-mono text-xs text-slate-500" { (&deployment.name) } + } + + div class="rounded-lg border border-slate-800 bg-slate-950 p-5" { + dl class="grid gap-4 text-sm sm:grid-cols-[12rem_1fr]" { + dt class="text-slate-500" { "Name" } + dd class="font-mono text-slate-200" { (&deployment.name) } + + dt class="text-slate-500" { "Status" } + dd { (badges::deployment_status(deployment.status)) } + + dt class="text-slate-500" { "Health" } + dd class="text-slate-300" { + (deployment.healthy_devices) " / " (deployment.target_devices) " healthy" + } + } + } + + div class="mt-6 rounded-lg border border-slate-800 bg-slate-950" { + div class="border-b border-slate-800 px-5 py-3" { + h3 class="text-sm font-medium text-slate-300" { "Target devices" } + } + + table class="min-w-full divide-y divide-slate-800 text-sm" { + tbody class="divide-y divide-slate-800" { + @for device in devices { + tr { + td class="px-5 py-2 font-mono text-slate-200" { (&device.id) } + td class="px-5 py-2 text-slate-400" { (badges::device_status(device.status)) } + } + } + } + } + } } } } diff --git a/fleet/harmony-fleet-operator/src/frontend/views/devices.rs b/fleet/harmony-fleet-operator/src/frontend/views/devices.rs index 6d9c6c99..88676a9c 100644 --- a/fleet/harmony-fleet-operator/src/frontend/views/devices.rs +++ b/fleet/harmony-fleet-operator/src/frontend/views/devices.rs @@ -1,5 +1,6 @@ -use maud::{Markup, html}; +use maud::{Markup, PreEscaped, html}; +use crate::frontend::views::badges; use crate::service::{DeviceStatus, DeviceSummary}; pub fn page(devices: &[DeviceSummary]) -> Markup { @@ -28,6 +29,7 @@ pub fn page(devices: &[DeviceSummary]) -> Markup { } } } + div id="modal-root" {} } } } @@ -37,8 +39,18 @@ pub fn page(devices: &[DeviceSummary]) -> Markup { pub fn row(d: &DeviceSummary) -> Markup { html! { tr id={"device-" (d.id)} { - td class="px-3 py-2 font-mono text-slate-200" { (d.id) } - td class="px-3 py-2" { (status_badge(d.status)) } + td class="px-3 py-2 font-mono" { + button + type="button" + class="text-slate-200 hover:text-orange-400 hover:underline" + hx-get={"/devices/" (d.id) "/logs"} + hx-target="#modal-root" + hx-swap="innerHTML" + { + (d.id) + } + } + td class="px-3 py-2" { (badges::device_status(d.status)) } td class="px-3 py-2 text-slate-300" { @if let Some(deployment) = &d.deployment { (deployment) } @else { span class="text-slate-600" { "—" } } @@ -67,17 +79,56 @@ pub fn row(d: &DeviceSummary) -> Markup { } } -fn status_badge(s: DeviceStatus) -> Markup { - let (label, classes) = match s { - DeviceStatus::Healthy => ("healthy", "bg-emerald-900 text-emerald-300"), - DeviceStatus::Pending => ("pending", "bg-amber-900 text-amber-300"), - DeviceStatus::Stale => ("stale", "bg-rose-900 text-rose-300"), - DeviceStatus::Blacklisted => ("blacklisted", "bg-slate-800 text-slate-400"), - DeviceStatus::Unknown => ("unknown", "bg-slate-800 text-slate-500"), - }; +pub fn logs_modal(device_id: &str) -> Markup { html! { - span class={"inline-block rounded px-2 py-0.5 text-xs font-medium " (classes)} { - (label) + dialog + id="device-logs-modal" + class="m-auto grid grid-rows-[auto_1fr] h-[88vh] w-[min(96vw,82rem)] overflow-hidden rounded-none border-t-2 border-x-0 border-b-0 border-orange-500 bg-[#080a0c] p-0 text-slate-100 shadow-[0_32px_64px_rgba(0,0,0,0.9),0_0_0_1px_rgba(148,163,184,0.06)] backdrop:bg-black/85" + onclick="if (event.target === this) this.close()" + onclose="document.getElementById('modal-root').innerHTML = ''" + { + div class="flex items-center justify-between border-b border-white/[0.06] bg-[#0c1018] px-5 py-3" { + div class="flex items-center gap-3" { + span class="relative flex h-1.5 w-1.5 shrink-0" { + span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-orange-400 opacity-60" {} + span class="relative inline-flex h-1.5 w-1.5 rounded-full bg-orange-500" {} + } + code class="text-sm font-medium text-slate-100" { (device_id) } + span class="text-[10px] font-semibold uppercase tracking-[0.15em] text-orange-500/60" { "· logs" } + } + form method="dialog" { + button + type="submit" + class="flex items-center gap-1.5 text-slate-500 transition-colors hover:text-slate-200" + aria-label="Close" + { + kbd class="rounded border border-slate-700/80 bg-slate-800/60 px-1.5 py-0.5 font-mono text-[10px] text-slate-400" { "esc" } + span class="text-xs" { "close" } + } + } + } + + div + class="overflow-y-auto bg-[#050608] py-3 font-mono text-xs leading-6" + hx-ext="sse" + sse-connect={"/devices/" (device_id) "/logs/stream"} + sse-swap="log" + hx-swap="beforeend" + { + div class="px-5 py-1 italic text-slate-700" { "— connecting —" } + } + } + script { + (PreEscaped(r#" +(function(){ + var modal = document.getElementById('device-logs-modal'); + modal?.showModal(); + var body = modal?.querySelector('[hx-swap]'); + if (!body) return; + new MutationObserver(function(){ body.scrollTop = body.scrollHeight; }) + .observe(body, { childList: true }); +})(); +"#)) } } } diff --git a/fleet/harmony-fleet-operator/src/frontend/views/mod.rs b/fleet/harmony-fleet-operator/src/frontend/views/mod.rs index ec2901b5..39866099 100644 --- a/fleet/harmony-fleet-operator/src/frontend/views/mod.rs +++ b/fleet/harmony-fleet-operator/src/frontend/views/mod.rs @@ -1,3 +1,4 @@ +pub mod badges; pub mod dashboard; pub mod deployments; pub mod devices; diff --git a/fleet/harmony-fleet-operator/src/main.rs b/fleet/harmony-fleet-operator/src/main.rs index 46300ee9..31534819 100644 --- a/fleet/harmony-fleet-operator/src/main.rs +++ b/fleet/harmony-fleet-operator/src/main.rs @@ -106,6 +106,8 @@ enum Command { #[tokio::main] async fn main() -> Result<()> { + dotenvy::dotenv().ok(); + tracing_subscriber::fmt() .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) .init(); -- 2.39.5 From 0f6b5912e3a810d5349030c26e898d0fd1070b7a Mon Sep 17 00:00:00 2001 From: Reda Tarzalt Date: Wed, 13 May 2026 16:01:57 -0400 Subject: [PATCH 02/18] remove sensitive logs --- .../src/frontend/server.rs | 22 ++++--------------- 1 file changed, 4 insertions(+), 18 deletions(-) diff --git a/fleet/harmony-fleet-operator/src/frontend/server.rs b/fleet/harmony-fleet-operator/src/frontend/server.rs index c83bef7d..bc51d4b1 100644 --- a/fleet/harmony-fleet-operator/src/frontend/server.rs +++ b/fleet/harmony-fleet-operator/src/frontend/server.rs @@ -162,11 +162,7 @@ async fn login_handler(cookies: Cookies) -> Result { .build(), ); - tracing::debug!(state = %attempt.state, "created OIDC login attempt"); - tracing::debug!( - verifier_len = attempt.pkce_code_verifier.len(), - "PKCE code verifier stored for callback validation" - ); + tracing::debug!("created OIDC login attempt"); Ok(Redirect::temporary(&attempt.authorize_url)) } @@ -199,12 +195,7 @@ async fn auth_callback_handler( ); - tracing::debug!( - subject = %user.subject, - email = ?user.email, - name = ?user.name, - "ID token validated" - ); + tracing::debug!("ID token validated; dashboard session created"); Ok(Redirect::to("/").into_response()) } @@ -212,14 +203,9 @@ async fn auth_callback_handler( Ok(AuthCallbackQuery::Failure { error, error_description, - state, + state: _, }) => { - tracing::warn!( - %error, - ?error_description, - ?state, - "SSO callback returned an error" - ); + tracing::warn!(%error, "SSO callback returned an error"); Ok(maud::html! { pre { -- 2.39.5 From 2c5f59525ff518522fede9ad57a76d6986095938 Mon Sep 17 00:00:00 2001 From: Reda Tarzalt Date: Thu, 14 May 2026 13:10:46 -0400 Subject: [PATCH 03/18] save tests --- .../src/frontend/auth.rs | 52 ++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/fleet/harmony-fleet-operator/src/frontend/auth.rs b/fleet/harmony-fleet-operator/src/frontend/auth.rs index 4a1fe33f..4a70cddf 100644 --- a/fleet/harmony-fleet-operator/src/frontend/auth.rs +++ b/fleet/harmony-fleet-operator/src/frontend/auth.rs @@ -52,7 +52,7 @@ pub struct LoginAttemptCookie { pub pkce_code_verifier: String, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct DashboardSession { pub subject: String, pub email: Option, @@ -158,6 +158,7 @@ pub fn encode_dashboard_session(session: &DashboardSession) -> anyhow::Result anyhow::Result { let decoded = URL_SAFE_NO_PAD .decode(value) @@ -265,3 +266,52 @@ fn pkce_s256_challenge(code_verifier: &str) -> String { let digest = Sha256::digest(code_verifier.as_bytes()); URL_SAFE_NO_PAD.encode(digest) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pkce_s256_challenge_test() { + let code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + let challenge = pkce_s256_challenge(code_verifier); + assert_eq!(challenge, "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"); + } + + #[test] + fn dashboard_session_test() { + let session = DashboardSession { + subject: "test".to_string(), + email: None, + name: None, + expires_at: chrono::Utc::now().timestamp() + 1000, + }; + + let encoded = encode_dashboard_session(&session).unwrap(); + let decoded = decode_dashboard_session(&encoded).unwrap(); + + assert_eq!(session, decoded); + } + + #[test] + fn expired_session_test() { + let session = DashboardSession { + subject: "test".to_string(), + email: None, + name: None, + expires_at: chrono::Utc::now().timestamp() - 1000, + }; + + let encoded = encode_dashboard_session(&session).unwrap(); + let decoded = decode_dashboard_session(&encoded); + + assert!(decoded.is_err()); + } + + // #[test] + // fn auth_callback_query_test() { + // let query = AuthCallbackQuery::Success { + // code: "test".to_string(), + // state: "test".to_string(), + // }; +} -- 2.39.5 From 72cca691b9614542afa0949178c093441f9ade25 Mon Sep 17 00:00:00 2001 From: Reda Tarzalt Date: Thu, 14 May 2026 15:41:33 -0400 Subject: [PATCH 04/18] move auth to lib --- harmony_zitadel_auth/Cargo.toml | 20 +++ harmony_zitadel_auth/src/lib.rs | 253 ++++++++++++++++++++++++++++++++ 2 files changed, 273 insertions(+) create mode 100644 harmony_zitadel_auth/Cargo.toml create mode 100644 harmony_zitadel_auth/src/lib.rs diff --git a/harmony_zitadel_auth/Cargo.toml b/harmony_zitadel_auth/Cargo.toml new file mode 100644 index 00000000..25ef962b --- /dev/null +++ b/harmony_zitadel_auth/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "harmony_zitadel_auth" +edition = "2024" +version.workspace = true +readme.workspace = true +license.workspace = true + +[dependencies] +anyhow.workspace = true +base64.workspace = true +chrono = { workspace = true, features = ["serde"] } +rand.workspace = true +reqwest.workspace = true +serde.workspace = true +serde_json.workspace = true +sha2 = "0.10" +url.workspace = true + +openidconnect = { version = "4", default-features = false, features = ["reqwest", "rustls-tls"] } + diff --git a/harmony_zitadel_auth/src/lib.rs b/harmony_zitadel_auth/src/lib.rs new file mode 100644 index 00000000..174f051c --- /dev/null +++ b/harmony_zitadel_auth/src/lib.rs @@ -0,0 +1,253 @@ +use std::str::FromStr; + +use anyhow::Result; +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use chrono::{Duration, Utc}; +use openidconnect::Nonce; +use openidconnect::core::{CoreClient, CoreIdToken, CoreProviderMetadata}; +use openidconnect::{ClientId, IssuerUrl}; +use rand::random; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use url::Url; + +#[derive(Debug, Clone)] +pub struct ZitadelAuthConfig { + pub authorize_url: String, + pub token_url: String, + pub issuer_url: String, + pub client_id: String, + pub redirect_uri: String, + pub scope: String, + pub trusted_audiences: Vec, +} + +#[derive(Debug, Clone)] +pub struct ValidatedUser { + pub subject: String, + pub email: Option, + pub name: Option, +} + +#[derive(Debug, Clone)] +pub struct LoginAttempt { + pub authorize_url: String, + pub state: String, + pub pkce_code_verifier: String, +} + +#[derive(Debug, Deserialize)] +pub struct TokenResponse { + pub access_token: String, + pub id_token: String, + pub token_type: String, + pub expires_in: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LoginAttemptCookie { + pub state: String, + pub pkce_code_verifier: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct HarmonyAuthSession { + pub subject: String, + pub email: Option, + pub name: Option, + pub expires_at: i64, +} + +#[derive(Debug, Deserialize)] +pub struct RawAuthCallbackQuery { + pub code: Option, + pub state: Option, + pub error: Option, + pub error_description: Option, +} + +#[derive(Debug)] +pub enum AuthCallbackQuery { + Success { + code: String, + state: String, + }, + Failure { + error: String, + error_description: Option, + }, +} + +impl From<&LoginAttempt> for LoginAttemptCookie { + fn from(attempt: &LoginAttempt) -> Self { + Self { + state: attempt.state.clone(), + pkce_code_verifier: attempt.pkce_code_verifier.clone(), + } + } +} + +impl TryFrom for AuthCallbackQuery { + type Error = anyhow::Error; + + fn try_from(raw: RawAuthCallbackQuery) -> Result { + match raw { + RawAuthCallbackQuery { + code: Some(code), + state: Some(state), + error: None, + error_description: None, + } => Ok(Self::Success { code, state }), + + RawAuthCallbackQuery { + code: None, + state: _, + error: Some(error), + error_description, + } => Ok(Self::Failure { + error, + error_description, + }), + + _ => Err(anyhow::anyhow!("invalid auth callback query shape")), + } + } +} + +pub async fn validate_id_token( + id_token: &str, + http_client: &reqwest::Client, + config: &ZitadelAuthConfig, +) -> anyhow::Result { + let provider_metadata = CoreProviderMetadata::discover_async( + IssuerUrl::new(config.issuer_url.clone())?, + http_client, + ) + .await?; + + let client = CoreClient::from_provider_metadata( + provider_metadata, + ClientId::new(config.client_id.clone()), + None, + ); + + let id_token = CoreIdToken::from_str(id_token)?; + let trusted_audiences = config.trusted_audiences.clone(); + let verifier = client + .id_token_verifier() + .set_other_audience_verifier_fn(move |aud| trusted_audiences.contains(&aud.to_string())); + let claims = id_token.claims(&verifier, |_: Option<&Nonce>| Ok(()))?; + let subject = claims.subject().to_string(); + let email = claims.email().map(|email| email.to_string()); + let name = claims + .name() + .and_then(|localized| localized.get(None)) + .map(|name| name.to_string()); + + Ok(ValidatedUser { + subject, + email, + name, + }) +} + +pub fn build_harmony_auth_session(user: &ValidatedUser, ttl: Duration) -> HarmonyAuthSession { + HarmonyAuthSession { + subject: user.subject.clone(), + email: user.email.clone(), + name: user.name.clone(), + expires_at: (Utc::now() + ttl).timestamp(), + } +} + +pub fn build_login_attempt(config: &ZitadelAuthConfig) -> Result { + let state = random_url_token(32); + let pkce_code_verifier = random_url_token(32); + let code_challenge = pkce_s256_challenge(&pkce_code_verifier); + + let mut url = Url::parse(&config.authorize_url)?; + url.query_pairs_mut() + .append_pair("client_id", &config.client_id) + .append_pair("redirect_uri", &config.redirect_uri) + .append_pair("response_type", "code") + .append_pair("scope", &config.scope) + .append_pair("code_challenge", &code_challenge) + .append_pair("code_challenge_method", "S256") + .append_pair("state", &state); + + Ok(LoginAttempt { + authorize_url: url.into(), + state, + pkce_code_verifier, + }) +} + +pub async fn exchange_code_for_token( + client: &reqwest::Client, + config: &ZitadelAuthConfig, + pkce_code_verifier: &str, + code: &str, +) -> anyhow::Result { + let response = client + .post(&config.token_url) + .form(&[ + ("grant_type", "authorization_code"), + ("code", code), + ("redirect_uri", &config.redirect_uri), + ("client_id", &config.client_id), + ("code_verifier", pkce_code_verifier), + ]) + .send() + .await?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + anyhow::bail!("failed to exchange code for token: {status} {body}"); + } + + Ok(response.json::().await?) +} + +fn pkce_s256_challenge(code_verifier: &str) -> String { + let digest = Sha256::digest(code_verifier.as_bytes()); + URL_SAFE_NO_PAD.encode(digest) +} + +fn random_url_token(byte_len: usize) -> String { + let mut bytes = vec![0u8; byte_len]; + for chunk in bytes.chunks_mut(32) { + let random_bytes: [u8; 32] = random(); + chunk.copy_from_slice(&random_bytes[..chunk.len()]); + } + URL_SAFE_NO_PAD.encode(bytes) +} + +pub fn validate_callback_state(attempt: &LoginAttemptCookie, returned_state: &str) -> Result<()> { + if attempt.state != returned_state { + anyhow::bail!("auth callback state mismatch; start again at /login"); + } + + Ok(()) +} + +pub fn validate_harmony_auth_session(session: &HarmonyAuthSession) -> Result<()> { + if session.expires_at <= Utc::now().timestamp() { + anyhow::bail!("auth session expired"); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pkce_s256_challenge_test() { + let code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + let challenge = pkce_s256_challenge(code_verifier); + assert_eq!(challenge, "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"); + } +} -- 2.39.5 From ab0d09f27db6ad377197a9ffd082a46d4cc8cb11 Mon Sep 17 00:00:00 2001 From: Reda Tarzalt Date: Thu, 14 May 2026 15:44:01 -0400 Subject: [PATCH 05/18] save code before lib refactor --- Cargo.lock | 61 ++++-- Cargo.toml | 1 + fleet/harmony-fleet-operator/Cargo.toml | 4 +- .../src/frontend/auth.rs | 78 +------ .../src/frontend/server.rs | 197 +++++++++++++----- fleet/harmony-fleet-operator/src/main.rs | 31 +++ 6 files changed, 233 insertions(+), 139 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ea60426a..2ef39468 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1062,6 +1062,29 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-extra" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9963ff19f40c6102c76756ef0a46004c0d58957d87259fc9208ff8441c12ab96" +dependencies = [ + "axum", + "axum-core", + "bytes 1.11.1", + "cookie 0.18.1", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "serde_core", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "backon" version = "1.6.0" @@ -1797,7 +1820,11 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" dependencies = [ + "aes-gcm", + "base64 0.22.1", "percent-encoding", + "rand 0.8.5", + "subtle", "time", "version_check", ] @@ -4057,6 +4084,7 @@ dependencies = [ "async-nats", "async-trait", "axum", + "axum-extra", "base64 0.22.1", "chrono", "clap", @@ -4079,7 +4107,6 @@ dependencies = [ "tokio", "tokio-stream", "toml", - "tower-cookies", "tracing", "tracing-subscriber", "url", @@ -4427,6 +4454,22 @@ dependencies = [ "url", ] +[[package]] +name = "harmony_zitadel_auth" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64 0.22.1", + "chrono", + "openidconnect", + "rand 0.9.2", + "reqwest 0.12.28", + "serde", + "serde_json", + "sha2", + "url", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -9046,22 +9089,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "tower-cookies" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "151b5a3e3c45df17466454bb74e9ecedecc955269bdedbf4d150dfa393b55a36" -dependencies = [ - "axum-core", - "cookie 0.18.1", - "futures-util", - "http 1.4.0", - "parking_lot", - "pin-project-lite", - "tower-layer", - "tower-service", -] - [[package]] name = "tower-http" version = "0.6.8" diff --git a/Cargo.toml b/Cargo.toml index 1cf47d01..9c382a89 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "examples/*", "private_repos/*", "harmony", + "harmony_zitadel_auth", "harmony_types", "harmony_macros", "harmony_tui", diff --git a/fleet/harmony-fleet-operator/Cargo.toml b/fleet/harmony-fleet-operator/Cargo.toml index 054d524b..72bc9d9f 100644 --- a/fleet/harmony-fleet-operator/Cargo.toml +++ b/fleet/harmony-fleet-operator/Cargo.toml @@ -11,7 +11,7 @@ default = [] # build time when the standalone `tailwindcss` CLI is on PATH; otherwise # the bundled CSS is empty and `--css-from ` must be used at runtime # (the sidecar-watch dev workflow does this). -web-frontend = ["dep:axum", "dep:maud", "dep:tokio-stream"] +web-frontend = ["dep:axum", "dep:axum-extra", "dep:maud", "dep:tokio-stream"] [dependencies] harmony = { path = "../../harmony", features = ["podman"] } @@ -40,8 +40,8 @@ sha2 = "0.10" reqwest.workspace = true axum = { version = "0.8", optional = true } +axum-extra = { version = "0.10", features = ["cookie", "cookie-private"], optional = true } maud = { version = "0.27", features = ["axum"], optional = true } openidconnect = { version = "4", default-features = false, features = ["reqwest", "rustls-tls"] } tokio-stream = { version = "0.1", optional = true } -tower-cookies = "0.11" dotenvy = "0.15" diff --git a/fleet/harmony-fleet-operator/src/frontend/auth.rs b/fleet/harmony-fleet-operator/src/frontend/auth.rs index 4a70cddf..76f461a8 100644 --- a/fleet/harmony-fleet-operator/src/frontend/auth.rs +++ b/fleet/harmony-fleet-operator/src/frontend/auth.rs @@ -4,17 +4,15 @@ use std::str::FromStr; use anyhow::Result; use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use chrono::{Duration, Utc}; +use openidconnect::Nonce; use openidconnect::core::{CoreClient, CoreIdToken, CoreProviderMetadata}; use openidconnect::reqwest; -use openidconnect::Nonce; -use openidconnect::{ - ClientId, IssuerUrl, -}; -use chrono::{Duration, Utc}; +use openidconnect::{ClientId, IssuerUrl}; +use rand::random; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use url::Url; -use rand::random; const AUTHORIZE_URL_ENV: &str = "FLEET_AUTH_AUTHORIZE_URL"; const CLIENT_ID_ENV: &str = "FLEET_AUTH_CLIENT_ID"; @@ -77,7 +75,6 @@ pub enum AuthCallbackQuery { Failure { error: String, error_description: Option, - state: Option, }, } @@ -104,13 +101,12 @@ impl TryFrom for AuthCallbackQuery { RawAuthCallbackQuery { code: None, - state, + state: _, error: Some(error), error_description, } => Ok(Self::Failure { error, error_description, - state, }), _ => Err(anyhow::anyhow!("invalid auth callback query shape")), @@ -154,26 +150,6 @@ pub fn build_dashboard_session(user: &ValidatedUser) -> DashboardSession { } } -pub fn encode_dashboard_session(session: &DashboardSession) -> anyhow::Result { - Ok(URL_SAFE_NO_PAD.encode(serde_json::to_vec(session)?)) -} - -// FIXME: encrypt session token or smth -pub fn decode_dashboard_session(value: &str) -> anyhow::Result { - let decoded = URL_SAFE_NO_PAD - .decode(value) - .map_err(|e| anyhow::anyhow!("invalid dashboard session cookie encoding: {e}"))?; - - let session: DashboardSession = serde_json::from_slice(&decoded) - .map_err(|e| anyhow::anyhow!("invalid dashboard session cookie payload: {e}"))?; - - if session.expires_at <= chrono::Utc::now().timestamp() { - anyhow::bail!("dashboard session expired"); - } - - Ok(session) - } - pub async fn validate_id_token( id_token: &str, http_client: &reqwest::Client, @@ -185,11 +161,8 @@ pub async fn validate_id_token( let provider_metadata = CoreProviderMetadata::discover_async(IssuerUrl::new(issuer_url)?, http_client).await?; - let client = CoreClient::from_provider_metadata( - provider_metadata, - ClientId::new(client_id), - None, - ); + let client = + CoreClient::from_provider_metadata(provider_metadata, ClientId::new(client_id), None); let id_token = CoreIdToken::from_str(id_token)?; let verifier = client @@ -277,41 +250,4 @@ mod tests { let challenge = pkce_s256_challenge(code_verifier); assert_eq!(challenge, "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"); } - - #[test] - fn dashboard_session_test() { - let session = DashboardSession { - subject: "test".to_string(), - email: None, - name: None, - expires_at: chrono::Utc::now().timestamp() + 1000, - }; - - let encoded = encode_dashboard_session(&session).unwrap(); - let decoded = decode_dashboard_session(&encoded).unwrap(); - - assert_eq!(session, decoded); - } - - #[test] - fn expired_session_test() { - let session = DashboardSession { - subject: "test".to_string(), - email: None, - name: None, - expires_at: chrono::Utc::now().timestamp() - 1000, - }; - - let encoded = encode_dashboard_session(&session).unwrap(); - let decoded = decode_dashboard_session(&encoded); - - assert!(decoded.is_err()); - } - - // #[test] - // fn auth_callback_query_test() { - // let query = AuthCallbackQuery::Success { - // code: "test".to_string(), - // state: "test".to_string(), - // }; } diff --git a/fleet/harmony-fleet-operator/src/frontend/server.rs b/fleet/harmony-fleet-operator/src/frontend/server.rs index bc51d4b1..dd921a32 100644 --- a/fleet/harmony-fleet-operator/src/frontend/server.rs +++ b/fleet/harmony-fleet-operator/src/frontend/server.rs @@ -7,27 +7,28 @@ use std::time::Duration; use anyhow::Result; use axum::Router; use axum::body::Body; -use axum::extract::{Path, Query, State}; -use axum::http::{StatusCode, header}; -use axum::response::{ Redirect, IntoResponse, Response }; -use axum::response::sse::{Event, KeepAlive, Sse}; -use axum::middleware::{self, Next}; +use axum::extract::{Extension, FromRef, Path, Query, State}; use axum::http::Request; +use axum::http::{StatusCode, header}; +use axum::middleware::{self, Next}; +use axum::response::sse::{Event, KeepAlive, Sse}; +use axum::response::{IntoResponse, Redirect, Response}; use axum::routing::{get, post}; +use axum_extra::extract::cookie::{Cookie, Key, PrivateCookieJar, SameSite}; use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use maud::Markup; use tokio_stream::StreamExt; use tokio_stream::wrappers::IntervalStream; -use tower_cookies::cookie::{Cookie, SameSite}; -use tower_cookies::{CookieManagerLayer, Cookies}; use super::assets::{HTMX_JS, HTMX_SSE_JS, TAILWIND_CSS}; use super::auth::LoginAttemptCookie; use super::layout::page; -use super::views::{dashboard, deployments as deployments_view, devices as devices_view}; -use crate::frontend::auth::{ exchange_code_for_token, validate_id_token, build_dashboard_session, encode_dashboard_session }; -use crate::frontend::auth::{self, AuthCallbackQuery, RawAuthCallbackQuery}; +use super::views::{ + dashboard, deployments as deployments_view, devices as devices_view, nav_demos, profile_demos, +}; +use crate::frontend::auth::{self, AuthCallbackQuery, DashboardSession, RawAuthCallbackQuery}; +use crate::frontend::auth::{build_dashboard_session, exchange_code_for_token, validate_id_token}; use crate::service::FleetService; /// Default high port — keeps clear of NATS (4222), k8s API (6443), @@ -39,6 +40,7 @@ const DASHBOARD_SESSION_COOKIE: &str = "harmony_fleet_session"; #[derive(Clone)] pub struct AppState { pub fleet: Arc, + pub cookie_key: Key, /// Read Tailwind CSS from this path on every request when set. /// Lets a sidecar `tailwindcss --watch` drive iteration without /// recompiling the binary. @@ -48,6 +50,12 @@ pub struct AppState { pub live_reload: bool, } +impl FromRef for Key { + fn from_ref(state: &AppState) -> Self { + state.cookie_key.clone() + } +} + pub struct Config { pub addr: SocketAddr, pub state: AppState, @@ -68,11 +76,18 @@ impl Config { } pub fn router(state: AppState) -> Router { - let public_routes = Router::new() .route("/login", get(login_handler)) .route("/logout", get(logout_handler)) .route("/auth/callback", get(auth_callback_handler)) + .route("/demo/profile-v1", get(demo_profile_v1)) + .route("/demo/profile-v2", get(demo_profile_v2)) + .route("/demo/profile-v3", get(demo_profile_v3)) + .route("/demo/profile-v4", get(demo_profile_v4)) + .route("/demo/nav-v1", get(demo_nav_v1)) + .route("/demo/nav-v2", get(demo_nav_v2)) + .route("/demo/nav-v3", get(demo_nav_v3)) + .route("/demo/nav-v4", get(demo_nav_v4)) .route("/static/tailwind.css", get(tailwind_css)) .route("/static/htmx.min.js", get(htmx_js)) .route("/static/htmx-ext-sse.js", get(htmx_sse_js)); @@ -85,7 +100,7 @@ pub fn router(state: AppState) -> Router { .route("/deployment/{id}", get(deployment_handler)) .route("/devices/{id}/logs", get(device_logs_handler)) .route("/devices/{id}/logs/stream", get(device_logs_stream_handler)) - .route_layer(middleware::from_fn(require_auth)); + .route_layer(middleware::from_fn_with_state(state.clone(), require_auth)); let mut r = public_routes.merge(private_routes); @@ -93,23 +108,23 @@ pub fn router(state: AppState) -> Router { r = r.route("/__dev/reload", get(dev_reload_sse)); } - r.layer(CookieManagerLayer::new()).with_state(state) + r.with_state(state) } -async fn require_auth(cookies: Cookies, mut req: Request, next: Next) -> Response { - let Some(cookie) = cookies.get(DASHBOARD_SESSION_COOKIE) else { +async fn require_auth(jar: PrivateCookieJar, mut req: Request, next: Next) -> Response { + let Some(cookie) = jar.get(DASHBOARD_SESSION_COOKIE) else { return unauthenticated_response(&req); }; - match auth::decode_dashboard_session(cookie.value()) { + match serde_json::from_str::(cookie.value()) { Ok(session) => { req.extensions_mut().insert(session); next.run(req).await } Err(e) => { tracing::warn!(%e, "invalid dashboard session"); - cookies.remove(Cookie::from(DASHBOARD_SESSION_COOKIE)); - unauthenticated_response(&req) + let jar = jar.remove(Cookie::from(DASHBOARD_SESSION_COOKIE)); + (jar, unauthenticated_response(&req)).into_response() } } } @@ -144,17 +159,17 @@ fn is_sse_request(req: &Request) -> bool { .is_some_and(|value| value.contains("text/event-stream")) } -async fn logout_handler(cookies: Cookies) -> Redirect { - cookies.remove(Cookie::from(DASHBOARD_SESSION_COOKIE)); - Redirect::to("/login") +async fn logout_handler(jar: PrivateCookieJar) -> impl IntoResponse { + let jar = jar.remove(Cookie::from(DASHBOARD_SESSION_COOKIE)); + (jar, Redirect::to("/login")) } -async fn login_handler(cookies: Cookies) -> Result { +async fn login_handler(jar: PrivateCookieJar) -> Result { let attempt = auth::build_login_attempt()?; let cookie_payload = LoginAttemptCookie::from(&attempt); let cookie_value = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&cookie_payload)?); - cookies.add( + let jar = jar.add( Cookie::build((LOGIN_ATTEMPT_COOKIE, cookie_value)) .http_only(true) .same_site(SameSite::Lax) @@ -164,46 +179,44 @@ async fn login_handler(cookies: Cookies) -> Result { tracing::debug!("created OIDC login attempt"); - Ok(Redirect::temporary(&attempt.authorize_url)) + Ok((jar, Redirect::temporary(&attempt.authorize_url))) } async fn auth_callback_handler( - cookies: Cookies, + jar: PrivateCookieJar, Query(raw): Query, ) -> Result { let query = AuthCallbackQuery::try_from(raw); match query { Ok(AuthCallbackQuery::Success { code, state }) => { - let attempt = read_login_attempt_cookie(&cookies)?; + let attempt = read_login_attempt_cookie(&jar)?; + let jar = jar.remove(Cookie::from(LOGIN_ATTEMPT_COOKIE)); - validate_callback_state(&cookies, &attempt, &state)?; + validate_callback_state(&attempt, &state)?; let client = reqwest::Client::new(); let tokens = exchange_code_for_token(&client, &attempt.pkce_code_verifier, &code).await?; let user = validate_id_token(&tokens.id_token, &client).await?; let session = build_dashboard_session(&user); - let session_cookie = encode_dashboard_session(&session)?; - cookies.add( - Cookie::build((DASHBOARD_SESSION_COOKIE, session_cookie)) - .http_only(true) - .same_site(SameSite::Lax) - .path("/") - .build(), + let jar = jar.add( + Cookie::build((DASHBOARD_SESSION_COOKIE, serde_json::to_string(&session)?)) + .http_only(true) + .same_site(SameSite::Lax) + .path("/") + .build(), ); - tracing::debug!("ID token validated; dashboard session created"); - Ok(Redirect::to("/").into_response()) + Ok((jar, Redirect::to("/")).into_response()) } Ok(AuthCallbackQuery::Failure { error, error_description, - state: _, }) => { tracing::warn!(%error, "SSO callback returned an error"); @@ -213,7 +226,8 @@ async fn auth_callback_handler( "Error: " (error) "\n" "Description: " (error_description.unwrap_or_default()) "\n" } - }.into_response()) + } + .into_response()) } Err(e) => { @@ -224,17 +238,16 @@ async fn auth_callback_handler( "SSO login failed\n" "Error: " (e.to_string()) "\n" } - }.into_response()) + } + .into_response()) } } } fn validate_callback_state( - cookies: &Cookies, attempt: &LoginAttemptCookie, returned_state: &str, ) -> Result<(), AppError> { - cookies.remove(Cookie::from(LOGIN_ATTEMPT_COOKIE)); // always clear the cookie if attempt.state != returned_state { return Err(anyhow::anyhow!("auth callback state mismatch; start again at /login").into()); } @@ -242,9 +255,9 @@ fn validate_callback_state( Ok(()) } -fn read_login_attempt_cookie(cookies: &Cookies) -> Result { - let attempt_cookie = cookies - .get("harmony_fleet_login_attempt") +fn read_login_attempt_cookie(jar: &PrivateCookieJar) -> Result { + let attempt_cookie = jar + .get(LOGIN_ATTEMPT_COOKIE) .ok_or_else(|| anyhow::anyhow!("missing login attempt cookie; start again at /login"))?; let decoded = URL_SAFE_NO_PAD @@ -288,21 +301,41 @@ async fn device_logs_stream_handler( // ---- handlers: each is a 3-liner: extract state, call service, render. ---- -async fn dashboard_handler(State(s): State) -> Result { +async fn dashboard_handler( + State(s): State, + session: Option>, +) -> Result { let summary = s.fleet.dashboard_summary().await?; - Ok(page("Dashboard", s.live_reload, dashboard::page(&summary))) + Ok(page( + "Dashboard", + s.live_reload, + session.as_ref().map(|e| &e.0), + dashboard::page(&summary), + )) } -async fn devices_handler(State(s): State) -> Result { +async fn devices_handler( + State(s): State, + session: Option>, +) -> Result { let devices = s.fleet.list_devices().await?; - Ok(page("Devices", s.live_reload, devices_view::page(&devices))) + Ok(page( + "Devices", + s.live_reload, + session.as_ref().map(|e| &e.0), + devices_view::page(&devices), + )) } -async fn deployments_handler(State(s): State) -> Result { +async fn deployments_handler( + State(s): State, + session: Option>, +) -> Result { let deployments = s.fleet.list_deployments().await?; Ok(page( "Deployments", s.live_reload, + session.as_ref().map(|e| &e.0), deployments_view::page(&deployments), )) } @@ -310,6 +343,7 @@ async fn deployments_handler(State(s): State) -> Result, Path(id): Path, + session: Option>, ) -> Result { let deployments = s.fleet.list_deployments().await?; let deployment = deployments @@ -326,6 +360,7 @@ async fn deployment_handler( Ok(page( "Deployment", s.live_reload, + session.as_ref().map(|e| &e.0), deployments_view::detail(deployment, &deployment_devices), )) } @@ -385,6 +420,70 @@ async fn dev_reload_sse() -> Sse) -> Markup { + let session = profile_demos::mock_session(); + page( + "Profile V1", + s.live_reload, + Some(&session), + profile_demos::v1_inline_minimal(&session), + ) +} + +async fn demo_profile_v2(State(s): State) -> Markup { + let session = profile_demos::mock_session(); + page( + "Profile V2", + s.live_reload, + Some(&session), + profile_demos::v2_compact_dropdown(&session), + ) +} + +async fn demo_profile_v3(State(s): State) -> Markup { + let session = profile_demos::mock_session(); + page( + "Profile V3", + s.live_reload, + Some(&session), + profile_demos::v3_profile_bar(&session), + ) +} + +async fn demo_profile_v4(State(s): State) -> Markup { + let session = profile_demos::mock_session(); + page( + "Profile V4", + s.live_reload, + Some(&session), + profile_demos::v4_detail_card(&session), + ) +} + +// ---- demo handlers: navigation shell options ---- + +async fn demo_nav_v1(State(_s): State) -> Markup { + let session = nav_demos::mock_session(); + nav_demos::shell_v1_sidebar(&session, nav_demos::demo_content()) +} + +async fn demo_nav_v2(State(_s): State) -> Markup { + let session = nav_demos::mock_session(); + nav_demos::shell_v2_top_dropdowns(&session, nav_demos::demo_content()) +} + +async fn demo_nav_v3(State(_s): State) -> Markup { + let session = nav_demos::mock_session(); + nav_demos::shell_v3_subnav(&session, nav_demos::demo_content()) +} + +async fn demo_nav_v4(State(_s): State) -> Markup { + let session = nav_demos::mock_session(); + nav_demos::shell_v4_pills(&session, nav_demos::demo_content()) +} + // ---- error type ---- pub struct AppError(anyhow::Error); diff --git a/fleet/harmony-fleet-operator/src/main.rs b/fleet/harmony-fleet-operator/src/main.rs index 31534819..4e199b2a 100644 --- a/fleet/harmony-fleet-operator/src/main.rs +++ b/fleet/harmony-fleet-operator/src/main.rs @@ -172,9 +172,12 @@ async fn serve_web( ); }; + let cookie_key = load_cookie_key()?; + frontend::server::run( Config::new(AppState { fleet, + cookie_key, css_override: css_from, live_reload, }) @@ -183,6 +186,34 @@ async fn serve_web( .await } +#[cfg(feature = "web-frontend")] +fn load_cookie_key() -> Result { + use axum_extra::extract::cookie::Key; + use base64::Engine; + use base64::engine::general_purpose::STANDARD; + + match std::env::var("FLEET_OPERATOR_COOKIE_KEY_B64") { + Ok(encoded) => { + let bytes = STANDARD + .decode(encoded.trim()) + .context("FLEET_OPERATOR_COOKIE_KEY_B64 must be standard base64")?; + if bytes.len() < 64 { + anyhow::bail!( + "FLEET_OPERATOR_COOKIE_KEY_B64 must decode to at least 64 bytes for private cookies" + ); + } + Ok(Key::from(&bytes)) + } + Err(std::env::VarError::NotPresent) => { + tracing::warn!( + "FLEET_OPERATOR_COOKIE_KEY_B64 is not set; generating an ephemeral development cookie key; sessions and in-flight login callbacks will not survive restart" + ); + Ok(Key::generate()) + } + Err(e) => Err(e).context("failed to read FLEET_OPERATOR_COOKIE_KEY_B64"), + } +} + async fn run(nats_url: &str, bucket: &str, credentials_toml: &str) -> Result<()> { let nats = connect_with_retry(nats_url, credentials_toml).await?; tracing::info!(url = %nats_url, "connected to NATS"); -- 2.39.5 From 40e350b4064b8fc05ee24a6afc7101e157c1125d Mon Sep 17 00:00:00 2001 From: Reda Tarzalt Date: Thu, 14 May 2026 17:04:05 -0400 Subject: [PATCH 06/18] use lib for auth --- Cargo.lock | 4 +- fleet/harmony-fleet-operator/Cargo.toml | 4 +- .../src/frontend/auth.rs | 255 +++--------------- .../src/frontend/server.rs | 30 +-- 4 files changed, 51 insertions(+), 242 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2ef39468..8a2ff872 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4093,16 +4093,14 @@ dependencies = [ "harmony", "harmony-fleet-auth", "harmony-reconciler-contracts", + "harmony_zitadel_auth", "k8s-openapi", "kube", "maud", - "openidconnect", - "rand 0.9.2", "reqwest 0.12.28", "schemars 0.8.22", "serde", "serde_json", - "sha2", "thiserror 2.0.18", "tokio", "tokio-stream", diff --git a/fleet/harmony-fleet-operator/Cargo.toml b/fleet/harmony-fleet-operator/Cargo.toml index 72bc9d9f..09488d25 100644 --- a/fleet/harmony-fleet-operator/Cargo.toml +++ b/fleet/harmony-fleet-operator/Cargo.toml @@ -17,6 +17,7 @@ web-frontend = ["dep:axum", "dep:axum-extra", "dep:maud", "dep:tokio-stream"] harmony = { path = "../../harmony", features = ["podman"] } harmony-fleet-auth = { path = "../harmony-fleet-auth" } harmony-reconciler-contracts = { path = "../../harmony-reconciler-contracts" } +harmony_zitadel_auth = { path = "../../harmony_zitadel_auth" } toml = { workspace = true } chrono = { workspace = true, features = ["serde"] } kube = { workspace = true, features = ["runtime", "derive"] } @@ -35,13 +36,10 @@ thiserror.workspace = true async-trait.workspace = true url.workspace = true base64.workspace = true -rand.workspace = true -sha2 = "0.10" reqwest.workspace = true axum = { version = "0.8", optional = true } axum-extra = { version = "0.10", features = ["cookie", "cookie-private"], optional = true } maud = { version = "0.27", features = ["axum"], optional = true } -openidconnect = { version = "4", default-features = false, features = ["reqwest", "rustls-tls"] } tokio-stream = { version = "0.1", optional = true } dotenvy = "0.15" diff --git a/fleet/harmony-fleet-operator/src/frontend/auth.rs b/fleet/harmony-fleet-operator/src/frontend/auth.rs index 76f461a8..ac6c79ab 100644 --- a/fleet/harmony-fleet-operator/src/frontend/auth.rs +++ b/fleet/harmony-fleet-operator/src/frontend/auth.rs @@ -1,18 +1,15 @@ use std::env; -use std::str::FromStr; use anyhow::Result; -use base64::Engine; -use base64::engine::general_purpose::URL_SAFE_NO_PAD; -use chrono::{Duration, Utc}; -use openidconnect::Nonce; -use openidconnect::core::{CoreClient, CoreIdToken, CoreProviderMetadata}; -use openidconnect::reqwest; -use openidconnect::{ClientId, IssuerUrl}; -use rand::random; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use url::Url; +use chrono::Duration; + +pub use harmony_zitadel_auth::{ + AuthCallbackQuery, HarmonyAuthSession as DashboardSession, LoginAttempt, LoginAttemptCookie, + RawAuthCallbackQuery, TokenResponse, ValidatedUser, build_harmony_auth_session, + exchange_code_for_token, validate_callback_state, validate_harmony_auth_session, + validate_id_token, +}; +use harmony_zitadel_auth::{ZitadelAuthConfig, build_login_attempt as build_zitadel_login_attempt}; const AUTHORIZE_URL_ENV: &str = "FLEET_AUTH_AUTHORIZE_URL"; const CLIENT_ID_ENV: &str = "FLEET_AUTH_CLIENT_ID"; @@ -22,202 +19,46 @@ const TOKEN_URL_ENV: &str = "FLEET_AUTH_TOKEN_URL"; const ISSUER_URL_ENV: &str = "FLEET_AUTH_ISSUER_URL"; const TRUSTED_AUDIENCES_ENV: &str = "FLEET_AUTH_TRUSTED_AUDIENCES"; -#[derive(Debug, Clone)] -pub struct ValidatedUser { - pub subject: String, - pub email: Option, - pub name: Option, -} - -#[derive(Debug, Deserialize)] -pub struct TokenResponse { - pub access_token: String, - pub id_token: String, - pub token_type: String, - pub expires_in: Option, -} - -#[derive(Debug, Clone)] -pub struct LoginAttempt { - pub authorize_url: String, - pub state: String, - pub pkce_code_verifier: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LoginAttemptCookie { - pub state: String, - pub pkce_code_verifier: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct DashboardSession { - pub subject: String, - pub email: Option, - pub name: Option, - pub expires_at: i64, -} - -#[derive(Debug, Deserialize)] -pub struct RawAuthCallbackQuery { - pub code: Option, - pub state: Option, - pub error: Option, - pub error_description: Option, -} - -#[derive(Debug)] -pub enum AuthCallbackQuery { - Success { - code: String, - state: String, - }, - Failure { - error: String, - error_description: Option, - }, -} - -impl From<&LoginAttempt> for LoginAttemptCookie { - fn from(attempt: &LoginAttempt) -> Self { - Self { - state: attempt.state.clone(), - pkce_code_verifier: attempt.pkce_code_verifier.clone(), - } - } -} - -impl TryFrom for AuthCallbackQuery { - type Error = anyhow::Error; - - fn try_from(raw: RawAuthCallbackQuery) -> Result { - match raw { - RawAuthCallbackQuery { - code: Some(code), - state: Some(state), - error: None, - error_description: None, - } => Ok(Self::Success { code, state }), - - RawAuthCallbackQuery { - code: None, - state: _, - error: Some(error), - error_description, - } => Ok(Self::Failure { - error, - error_description, - }), - - _ => Err(anyhow::anyhow!("invalid auth callback query shape")), - } - } -} - pub fn build_login_attempt() -> Result { - let state = random_url_token(32); - let pkce_code_verifier = random_url_token(32); - let code_challenge = pkce_s256_challenge(&pkce_code_verifier); - - let authorize_url = required_env(AUTHORIZE_URL_ENV)?; - let client_id = required_env(CLIENT_ID_ENV)?; - let redirect_uri = required_env(REDIRECT_URI_ENV)?; - let scope = required_env(SCOPE_ENV)?; - - let mut url = Url::parse(&authorize_url)?; - url.query_pairs_mut() - .append_pair("client_id", &client_id) - .append_pair("redirect_uri", &redirect_uri) - .append_pair("response_type", "code") - .append_pair("scope", &scope) - .append_pair("code_challenge", &code_challenge) - .append_pair("code_challenge_method", "S256") - .append_pair("state", &state); - - Ok(LoginAttempt { - authorize_url: url.into(), - state, - pkce_code_verifier, - }) + build_zitadel_login_attempt(&config()?) } pub fn build_dashboard_session(user: &ValidatedUser) -> DashboardSession { - DashboardSession { - subject: user.subject.clone(), - email: user.email.clone(), - name: user.name.clone(), - expires_at: (Utc::now() + Duration::hours(8)).timestamp(), - } + build_harmony_auth_session(user, Duration::hours(8)) } -pub async fn validate_id_token( - id_token: &str, - http_client: &reqwest::Client, -) -> anyhow::Result { - let issuer_url = required_env(ISSUER_URL_ENV)?; - let client_id = required_env(CLIENT_ID_ENV)?; - let trusted_audiences = trusted_audiences()?; - - let provider_metadata = - CoreProviderMetadata::discover_async(IssuerUrl::new(issuer_url)?, http_client).await?; - - let client = - CoreClient::from_provider_metadata(provider_metadata, ClientId::new(client_id), None); - - let id_token = CoreIdToken::from_str(id_token)?; - let verifier = client - .id_token_verifier() - .set_other_audience_verifier_fn(move |aud| trusted_audiences.contains(&aud.to_string())); - let claims = id_token.claims(&verifier, |_: Option<&Nonce>| Ok(()))?; - let subject = claims.subject().to_string(); - let email = claims.email().map(|email| email.to_string()); - let name = claims - .name() - .and_then(|localized| localized.get(None)) - .map(|name| name.to_string()); - - Ok(ValidatedUser { - subject, - email, - name, - }) -} - -pub async fn exchange_code_for_token( +pub async fn exchange_dashboard_code_for_token( client: &reqwest::Client, pkce_code_verifier: &str, code: &str, -) -> anyhow::Result { - let token_url = required_env(TOKEN_URL_ENV)?; - let redirect_uri = required_env(REDIRECT_URI_ENV)?; - let client_id = required_env(CLIENT_ID_ENV)?; - - let response = client - .post(token_url) - .form(&[ - ("grant_type", "authorization_code"), - ("code", code), - ("redirect_uri", &redirect_uri), - ("client_id", &client_id), - ("code_verifier", pkce_code_verifier), - ]) - .send() - .await?; - - if !response.status().is_success() { - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - anyhow::bail!("failed to exchange code for token: {status} {body}"); - } - - Ok(response.json::().await?) +) -> Result { + exchange_code_for_token(client, &config()?, pkce_code_verifier, code).await } -fn required_env(name: &str) -> anyhow::Result { +pub async fn validate_dashboard_id_token( + id_token: &str, + http_client: &reqwest::Client, +) -> Result { + validate_id_token(id_token, http_client, &config()?).await +} + +fn config() -> Result { + Ok(ZitadelAuthConfig { + authorize_url: required_env(AUTHORIZE_URL_ENV)?, + token_url: required_env(TOKEN_URL_ENV)?, + issuer_url: required_env(ISSUER_URL_ENV)?, + client_id: required_env(CLIENT_ID_ENV)?, + redirect_uri: required_env(REDIRECT_URI_ENV)?, + scope: required_env(SCOPE_ENV)?, + trusted_audiences: trusted_audiences()?, + }) +} + +fn required_env(name: &str) -> Result { env::var(name).map_err(|_| anyhow::anyhow!("missing required environment variable {name}")) } -fn trusted_audiences() -> anyhow::Result> { +fn trusted_audiences() -> Result> { Ok(required_env(TRUSTED_AUDIENCES_ENV)? .split(',') .map(str::trim) @@ -225,29 +66,3 @@ fn trusted_audiences() -> anyhow::Result> { .map(ToOwned::to_owned) .collect()) } - -fn random_url_token(byte_len: usize) -> String { - let mut bytes = vec![0u8; byte_len]; - for chunk in bytes.chunks_mut(32) { - let random_bytes: [u8; 32] = random(); - chunk.copy_from_slice(&random_bytes[..chunk.len()]); - } - URL_SAFE_NO_PAD.encode(bytes) -} - -fn pkce_s256_challenge(code_verifier: &str) -> String { - let digest = Sha256::digest(code_verifier.as_bytes()); - URL_SAFE_NO_PAD.encode(digest) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn pkce_s256_challenge_test() { - let code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; - let challenge = pkce_s256_challenge(code_verifier); - assert_eq!(challenge, "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"); - } -} diff --git a/fleet/harmony-fleet-operator/src/frontend/server.rs b/fleet/harmony-fleet-operator/src/frontend/server.rs index dd921a32..6c181ab5 100644 --- a/fleet/harmony-fleet-operator/src/frontend/server.rs +++ b/fleet/harmony-fleet-operator/src/frontend/server.rs @@ -28,7 +28,9 @@ use super::views::{ dashboard, deployments as deployments_view, devices as devices_view, nav_demos, profile_demos, }; use crate::frontend::auth::{self, AuthCallbackQuery, DashboardSession, RawAuthCallbackQuery}; -use crate::frontend::auth::{build_dashboard_session, exchange_code_for_token, validate_id_token}; +use crate::frontend::auth::{ + build_dashboard_session, exchange_dashboard_code_for_token, validate_dashboard_id_token, +}; use crate::service::FleetService; /// Default high port — keeps clear of NATS (4222), k8s API (6443), @@ -116,7 +118,13 @@ async fn require_auth(jar: PrivateCookieJar, mut req: Request, next: Next) return unauthenticated_response(&req); }; - match serde_json::from_str::(cookie.value()) { + let session = serde_json::from_str::(cookie.value()).and_then(|session| { + auth::validate_harmony_auth_session(&session) + .map(|_| session) + .map_err(serde::de::Error::custom) + }); + + match session { Ok(session) => { req.extensions_mut().insert(session); next.run(req).await @@ -193,12 +201,13 @@ async fn auth_callback_handler( let attempt = read_login_attempt_cookie(&jar)?; let jar = jar.remove(Cookie::from(LOGIN_ATTEMPT_COOKIE)); - validate_callback_state(&attempt, &state)?; + auth::validate_callback_state(&attempt, &state)?; let client = reqwest::Client::new(); let tokens = - exchange_code_for_token(&client, &attempt.pkce_code_verifier, &code).await?; - let user = validate_id_token(&tokens.id_token, &client).await?; + exchange_dashboard_code_for_token(&client, &attempt.pkce_code_verifier, &code) + .await?; + let user = validate_dashboard_id_token(&tokens.id_token, &client).await?; let session = build_dashboard_session(&user); let jar = jar.add( @@ -244,17 +253,6 @@ async fn auth_callback_handler( } } -fn validate_callback_state( - attempt: &LoginAttemptCookie, - returned_state: &str, -) -> Result<(), AppError> { - if attempt.state != returned_state { - return Err(anyhow::anyhow!("auth callback state mismatch; start again at /login").into()); - } - - Ok(()) -} - fn read_login_attempt_cookie(jar: &PrivateCookieJar) -> Result { let attempt_cookie = jar .get(LOGIN_ATTEMPT_COOKIE) -- 2.39.5 From 3f47a42530aeecc11069aaa1867746bf79694838 Mon Sep 17 00:00:00 2001 From: Reda Tarzalt Date: Fri, 15 May 2026 09:30:29 -0400 Subject: [PATCH 07/18] add logout to lib and refactor env config --- .../src/frontend/auth.rs | 39 ++++++++----------- .../src/frontend/layout.rs | 34 ++++++++++++++-- .../src/frontend/server.rs | 27 +++++++++---- harmony_zitadel_auth/src/lib.rs | 37 +++++++++++++----- 4 files changed, 95 insertions(+), 42 deletions(-) diff --git a/fleet/harmony-fleet-operator/src/frontend/auth.rs b/fleet/harmony-fleet-operator/src/frontend/auth.rs index ac6c79ab..0e523b88 100644 --- a/fleet/harmony-fleet-operator/src/frontend/auth.rs +++ b/fleet/harmony-fleet-operator/src/frontend/auth.rs @@ -9,22 +9,25 @@ pub use harmony_zitadel_auth::{ exchange_code_for_token, validate_callback_state, validate_harmony_auth_session, validate_id_token, }; -use harmony_zitadel_auth::{ZitadelAuthConfig, build_login_attempt as build_zitadel_login_attempt}; +use harmony_zitadel_auth::{ZitadelAuthConfig, build_login_attempt as build_zitadel_login_attempt, build_logout_url as build_zitadel_logout_url}; +use url::Url; -const AUTHORIZE_URL_ENV: &str = "FLEET_AUTH_AUTHORIZE_URL"; +const ZITADEL_BASE_ENV: &str = "FLEET_AUTH_ZITADEL_BASE"; +const BASE_URL_ENV: &str = "BASE_URL"; const CLIENT_ID_ENV: &str = "FLEET_AUTH_CLIENT_ID"; -const REDIRECT_URI_ENV: &str = "FLEET_AUTH_REDIRECT_URI"; const SCOPE_ENV: &str = "FLEET_AUTH_SCOPE"; -const TOKEN_URL_ENV: &str = "FLEET_AUTH_TOKEN_URL"; -const ISSUER_URL_ENV: &str = "FLEET_AUTH_ISSUER_URL"; const TRUSTED_AUDIENCES_ENV: &str = "FLEET_AUTH_TRUSTED_AUDIENCES"; pub fn build_login_attempt() -> Result { build_zitadel_login_attempt(&config()?) } -pub fn build_dashboard_session(user: &ValidatedUser) -> DashboardSession { - build_harmony_auth_session(user, Duration::hours(8)) +pub fn build_dashboard_session(user: &ValidatedUser, tokens: &TokenResponse) -> DashboardSession { + build_harmony_auth_session(user, tokens, Duration::hours(8)) +} + +pub fn build_logout_url(id_token: &str) -> Result { + build_zitadel_logout_url(&config()?, id_token) } pub async fn exchange_dashboard_code_for_token( @@ -42,27 +45,19 @@ pub async fn validate_dashboard_id_token( validate_id_token(id_token, http_client, &config()?).await } -fn config() -> Result { +pub fn config() -> Result { Ok(ZitadelAuthConfig { - authorize_url: required_env(AUTHORIZE_URL_ENV)?, - token_url: required_env(TOKEN_URL_ENV)?, - issuer_url: required_env(ISSUER_URL_ENV)?, + zitadel_base: required_env(ZITADEL_BASE_ENV)?, + base_url: required_env(BASE_URL_ENV)?, client_id: required_env(CLIENT_ID_ENV)?, - redirect_uri: required_env(REDIRECT_URI_ENV)?, scope: required_env(SCOPE_ENV)?, - trusted_audiences: trusted_audiences()?, + trusted_audiences: required_env(TRUSTED_AUDIENCES_ENV)? + .split(',') + .map(str::to_string) + .collect(), }) } fn required_env(name: &str) -> Result { env::var(name).map_err(|_| anyhow::anyhow!("missing required environment variable {name}")) } - -fn trusted_audiences() -> Result> { - Ok(required_env(TRUSTED_AUDIENCES_ENV)? - .split(',') - .map(str::trim) - .filter(|aud| !aud.is_empty()) - .map(ToOwned::to_owned) - .collect()) -} diff --git a/fleet/harmony-fleet-operator/src/frontend/layout.rs b/fleet/harmony-fleet-operator/src/frontend/layout.rs index be075ae7..03774918 100644 --- a/fleet/harmony-fleet-operator/src/frontend/layout.rs +++ b/fleet/harmony-fleet-operator/src/frontend/layout.rs @@ -2,7 +2,14 @@ use maud::{DOCTYPE, Markup, PreEscaped, html}; -pub fn page(title: &str, live_reload: bool, content: Markup) -> Markup { +use crate::frontend::auth::DashboardSession; + +pub fn page( + title: &str, + live_reload: bool, + session: Option<&DashboardSession>, + content: Markup, +) -> Markup { html! { (DOCTYPE) html lang="en" { @@ -25,8 +32,13 @@ pub fn page(title: &str, live_reload: bool, content: Markup) -> Markup { a href="/devices" class="hover:text-slate-100" { "Devices" } a href="/deployments" class="hover:text-slate-100" { "Deployments" } } - @if live_reload { - span class="ml-auto text-xs text-amber-400" { "dev · live reload" } + div class="ml-auto flex items-baseline gap-4" { + @if let Some(s) = session { + (user_nav_v1(s)) + } + @if live_reload { + span class="text-xs text-amber-400" { "dev · live reload" } + } } } main class="p-6 space-y-8" { (content) } @@ -35,6 +47,22 @@ pub fn page(title: &str, live_reload: bool, content: Markup) -> Markup { } } +/// V1: Inline Minimal — user info displayed directly in the header. +/// Recommended for operational dashboards where density and visibility matter. +fn user_nav_v1(session: &DashboardSession) -> Markup { + let display = session + .name + .as_deref() + .or(session.email.as_deref()) + .unwrap_or(&session.subject); + html! { + div class="flex items-baseline gap-3 text-sm" { + span class="text-slate-400" { (display) } + a href="/logout" class="text-slate-500 hover:text-rose-400 transition-colors duration-150" { "Log out" } + } + } +} + /// Tiny inline script: reconnects an EventSource to `/__dev/reload`; /// when the server comes back up after a restart, reload the page. const LIVE_RELOAD_JS: &str = r#" diff --git a/fleet/harmony-fleet-operator/src/frontend/server.rs b/fleet/harmony-fleet-operator/src/frontend/server.rs index 6c181ab5..8163b88e 100644 --- a/fleet/harmony-fleet-operator/src/frontend/server.rs +++ b/fleet/harmony-fleet-operator/src/frontend/server.rs @@ -22,14 +22,14 @@ use tokio_stream::StreamExt; use tokio_stream::wrappers::IntervalStream; use super::assets::{HTMX_JS, HTMX_SSE_JS, TAILWIND_CSS}; -use super::auth::LoginAttemptCookie; +use super::auth::{LoginAttemptCookie}; use super::layout::page; use super::views::{ dashboard, deployments as deployments_view, devices as devices_view, nav_demos, profile_demos, }; use crate::frontend::auth::{self, AuthCallbackQuery, DashboardSession, RawAuthCallbackQuery}; use crate::frontend::auth::{ - build_dashboard_session, exchange_dashboard_code_for_token, validate_dashboard_id_token, + build_dashboard_session, exchange_dashboard_code_for_token, validate_dashboard_id_token, build_logout_url }; use crate::service::FleetService; @@ -80,7 +80,6 @@ impl Config { pub fn router(state: AppState) -> Router { let public_routes = Router::new() .route("/login", get(login_handler)) - .route("/logout", get(logout_handler)) .route("/auth/callback", get(auth_callback_handler)) .route("/demo/profile-v1", get(demo_profile_v1)) .route("/demo/profile-v2", get(demo_profile_v2)) @@ -96,6 +95,7 @@ pub fn router(state: AppState) -> Router { let private_routes = Router::new() .route("/", get(dashboard_handler)) + .route("/logout", get(logout_handler)) .route("/devices", get(devices_handler)) .route("/devices/{id}/blacklist", post(blacklist_handler)) .route("/deployments", get(deployments_handler)) @@ -167,9 +167,22 @@ fn is_sse_request(req: &Request) -> bool { .is_some_and(|value| value.contains("text/event-stream")) } -async fn logout_handler(jar: PrivateCookieJar) -> impl IntoResponse { - let jar = jar.remove(Cookie::from(DASHBOARD_SESSION_COOKIE)); - (jar, Redirect::to("/login")) +async fn logout_handler(jar: PrivateCookieJar) -> Result { + let id_token = jar.get( + DASHBOARD_SESSION_COOKIE) + .map(|c| serde_json::from_str::(c.value())) + .and_then(|r| r.ok()) + .map(|s| s.id_token) + .unwrap_or_default(); + + let jar = jar.remove( + Cookie::build(DASHBOARD_SESSION_COOKIE) + .path("/") + .build() + ); + + let logout_url = build_logout_url(id_token.as_str())?; + Ok((jar, Redirect::to(logout_url.as_str()))) } async fn login_handler(jar: PrivateCookieJar) -> Result { @@ -208,7 +221,7 @@ async fn auth_callback_handler( exchange_dashboard_code_for_token(&client, &attempt.pkce_code_verifier, &code) .await?; let user = validate_dashboard_id_token(&tokens.id_token, &client).await?; - let session = build_dashboard_session(&user); + let session = build_dashboard_session(&user, &tokens); let jar = jar.add( Cookie::build((DASHBOARD_SESSION_COOKIE, serde_json::to_string(&session)?)) diff --git a/harmony_zitadel_auth/src/lib.rs b/harmony_zitadel_auth/src/lib.rs index 174f051c..80230c94 100644 --- a/harmony_zitadel_auth/src/lib.rs +++ b/harmony_zitadel_auth/src/lib.rs @@ -14,11 +14,9 @@ use url::Url; #[derive(Debug, Clone)] pub struct ZitadelAuthConfig { - pub authorize_url: String, - pub token_url: String, - pub issuer_url: String, + pub zitadel_base: String, + pub base_url: String, pub client_id: String, - pub redirect_uri: String, pub scope: String, pub trusted_audiences: Vec, } @@ -57,6 +55,7 @@ pub struct HarmonyAuthSession { pub email: Option, pub name: Option, pub expires_at: i64, + pub id_token: String, } #[derive(Debug, Deserialize)] @@ -88,6 +87,15 @@ impl From<&LoginAttempt> for LoginAttemptCookie { } } +impl ZitadelAuthConfig { + pub fn issuer_url(&self) -> String { self.zitadel_base.clone() } + pub fn authorize_url(&self) -> String { format!("{}/oauth/v2/authorize", self.zitadel_base) } + pub fn token_url(&self) -> String { format!("{}/oauth/v2/token", self.zitadel_base) } + pub fn logout_url(&self) -> String { format!("{}/oidc/v1/end_session", self.zitadel_base) } + pub fn redirect_uri(&self) -> String { format!("{}/auth/callback", self.base_url) } + pub fn logout_redirect_uri(&self) -> String { format!("{}/", self.base_url) } +} + impl TryFrom for AuthCallbackQuery { type Error = anyhow::Error; @@ -121,7 +129,7 @@ pub async fn validate_id_token( config: &ZitadelAuthConfig, ) -> anyhow::Result { let provider_metadata = CoreProviderMetadata::discover_async( - IssuerUrl::new(config.issuer_url.clone())?, + IssuerUrl::new(config.issuer_url())?, http_client, ) .await?; @@ -152,24 +160,33 @@ pub async fn validate_id_token( }) } -pub fn build_harmony_auth_session(user: &ValidatedUser, ttl: Duration) -> HarmonyAuthSession { +pub fn build_harmony_auth_session(user: &ValidatedUser, tokens: &TokenResponse, ttl: Duration) -> HarmonyAuthSession { HarmonyAuthSession { subject: user.subject.clone(), email: user.email.clone(), name: user.name.clone(), expires_at: (Utc::now() + ttl).timestamp(), + id_token: tokens.id_token.clone(), } } +pub fn build_logout_url(config: &ZitadelAuthConfig, id_token: &str) -> Result { + let mut url = Url::parse(&config.logout_url())?; + url.query_pairs_mut() + .append_pair("post_logout_redirect_uri", "http://localhost:18080/") + .append_pair("id_token_hint", id_token); + Ok(url) +} + pub fn build_login_attempt(config: &ZitadelAuthConfig) -> Result { let state = random_url_token(32); let pkce_code_verifier = random_url_token(32); let code_challenge = pkce_s256_challenge(&pkce_code_verifier); - let mut url = Url::parse(&config.authorize_url)?; + let mut url = Url::parse(&config.authorize_url())?; url.query_pairs_mut() .append_pair("client_id", &config.client_id) - .append_pair("redirect_uri", &config.redirect_uri) + .append_pair("redirect_uri", &config.redirect_uri()) .append_pair("response_type", "code") .append_pair("scope", &config.scope) .append_pair("code_challenge", &code_challenge) @@ -190,11 +207,11 @@ pub async fn exchange_code_for_token( code: &str, ) -> anyhow::Result { let response = client - .post(&config.token_url) + .post(&config.token_url()) .form(&[ ("grant_type", "authorization_code"), ("code", code), - ("redirect_uri", &config.redirect_uri), + ("redirect_uri", &config.redirect_uri()), ("client_id", &config.client_id), ("code_verifier", pkce_code_verifier), ]) -- 2.39.5 From 0018b9e455d1800853bc47ac8aa1cb91e706a734 Mon Sep 17 00:00:00 2001 From: Reda Tarzalt Date: Fri, 15 May 2026 10:41:20 -0400 Subject: [PATCH 08/18] update frontend design --- .../src/frontend/layout.rs | 104 ++++++++++++++---- .../src/frontend/server.rs | 80 +------------- 2 files changed, 86 insertions(+), 98 deletions(-) diff --git a/fleet/harmony-fleet-operator/src/frontend/layout.rs b/fleet/harmony-fleet-operator/src/frontend/layout.rs index 03774918..e69aa4a5 100644 --- a/fleet/harmony-fleet-operator/src/frontend/layout.rs +++ b/fleet/harmony-fleet-operator/src/frontend/layout.rs @@ -1,12 +1,22 @@ -//! Page shell — ``, ``, top nav, body slot. +//! Page shell — full-document wrapper with left sidebar navigation. use maud::{DOCTYPE, Markup, PreEscaped, html}; use crate::frontend::auth::DashboardSession; +// Inline SVG icons (no external dependency). +const ICON_DEVICES: &str = r#""#; +const ICON_DEPLOY: &str = r#""#; +const ICON_DASHBOARD: &str = r#""#; +const ICON_LOGOUT: &str = r#""#; + +/// Render a full page with the left sidebar layout. +/// +/// `current_path` is used to highlight the active nav item (e.g. "/", "/devices"). pub fn page( title: &str, live_reload: bool, + current_path: &str, session: Option<&DashboardSession>, content: Markup, ) -> Markup { @@ -25,40 +35,88 @@ pub fn page( } } body class="min-h-screen bg-slate-950 text-slate-100" hx-ext="sse" { - header class="border-b border-slate-800 px-6 py-4 flex items-baseline gap-6" { - h1 class="text-xl font-semibold" { "Harmony Fleet Operator" } - nav class="flex gap-4 text-sm text-slate-400" { - a href="/" class="hover:text-slate-100" { "Dashboard" } - a href="/devices" class="hover:text-slate-100" { "Devices" } - a href="/deployments" class="hover:text-slate-100" { "Deployments" } - } - div class="ml-auto flex items-baseline gap-4" { - @if let Some(s) = session { - (user_nav_v1(s)) - } - @if live_reload { - span class="text-xs text-amber-400" { "dev · live reload" } - } - } + div class="flex h-screen overflow-hidden" { + (sidebar(current_path, session)) + main class="flex-1 overflow-y-auto p-6" { (content) } } - main class="p-6 space-y-8" { (content) } } } } } -/// V1: Inline Minimal — user info displayed directly in the header. -/// Recommended for operational dashboards where density and visibility matter. -fn user_nav_v1(session: &DashboardSession) -> Markup { +fn sidebar(current_path: &str, session: Option<&DashboardSession>) -> Markup { + html! { + aside class="w-56 shrink-0 border-r border-slate-800 bg-slate-950 flex flex-col" { + div class="px-4 py-4 border-b border-slate-800" { + h1 class="text-sm font-semibold tracking-tight" { "Harmony Fleet" } + } + nav class="flex-1 overflow-y-auto px-3 py-3 space-y-0.5" { + (nav_link("/", ICON_DASHBOARD, "Dashboard", current_path == "/")) + (nav_link("/devices", ICON_DEVICES, "Devices", current_path == "/devices")) + (nav_link("/deployments", ICON_DEPLOY, "Deployments", current_path == "/deployments")) + } + @if let Some(s) = session { + (user_footer(s)) + } + } + } +} + +fn nav_link(href: &str, icon: &str, label: &str, active: bool) -> Markup { + let base = "flex items-center gap-2.5 px-3 py-2 rounded-md text-sm transition-colors duration-150"; + let cls = if active { + "bg-slate-800 text-slate-100 font-medium" + } else { + "text-slate-400 hover:bg-slate-900 hover:text-slate-200" + }; + html! { + a href=(href) class={(base) " " (cls)} { + (PreEscaped(icon)) + (label) + } + } +} + +fn user_footer(session: &DashboardSession) -> Markup { + let label = session + .name + .as_deref() + .and_then(|name| { + let initials: String = name + .split_whitespace() + .filter_map(|w| w.chars().next()) + .collect::() + .to_uppercase(); + if initials.is_empty() { None } else { Some(initials) } + }) + .unwrap_or_else(|| session.subject.chars().take(2).collect::().to_uppercase()); + let display = session .name .as_deref() .or(session.email.as_deref()) .unwrap_or(&session.subject); + html! { - div class="flex items-baseline gap-3 text-sm" { - span class="text-slate-400" { (display) } - a href="/logout" class="text-slate-500 hover:text-rose-400 transition-colors duration-150" { "Log out" } + div class="border-t border-slate-800 p-3" { + div class="flex items-center gap-3" { + div class="flex items-center justify-center w-8 h-8 rounded-full bg-slate-800 text-xs font-medium text-slate-300 shrink-0" { + (label) + } + div class="min-w-0" { + p class="text-sm text-slate-200 truncate" { (display) } + @if let Some(email) = session.email.as_deref() { + p class="text-xs text-slate-500 truncate" { (email) } + } + } + } + a + href="/logout" + class="mt-2 flex items-center gap-2 text-xs text-slate-500 hover:text-rose-400 transition-colors duration-150" + { + (PreEscaped(ICON_LOGOUT)) + "Log out" + } } } } diff --git a/fleet/harmony-fleet-operator/src/frontend/server.rs b/fleet/harmony-fleet-operator/src/frontend/server.rs index 8163b88e..86e02f75 100644 --- a/fleet/harmony-fleet-operator/src/frontend/server.rs +++ b/fleet/harmony-fleet-operator/src/frontend/server.rs @@ -24,9 +24,7 @@ use tokio_stream::wrappers::IntervalStream; use super::assets::{HTMX_JS, HTMX_SSE_JS, TAILWIND_CSS}; use super::auth::{LoginAttemptCookie}; use super::layout::page; -use super::views::{ - dashboard, deployments as deployments_view, devices as devices_view, nav_demos, profile_demos, -}; +use super::views::{dashboard, deployments as deployments_view, devices as devices_view}; use crate::frontend::auth::{self, AuthCallbackQuery, DashboardSession, RawAuthCallbackQuery}; use crate::frontend::auth::{ build_dashboard_session, exchange_dashboard_code_for_token, validate_dashboard_id_token, build_logout_url @@ -81,14 +79,6 @@ pub fn router(state: AppState) -> Router { let public_routes = Router::new() .route("/login", get(login_handler)) .route("/auth/callback", get(auth_callback_handler)) - .route("/demo/profile-v1", get(demo_profile_v1)) - .route("/demo/profile-v2", get(demo_profile_v2)) - .route("/demo/profile-v3", get(demo_profile_v3)) - .route("/demo/profile-v4", get(demo_profile_v4)) - .route("/demo/nav-v1", get(demo_nav_v1)) - .route("/demo/nav-v2", get(demo_nav_v2)) - .route("/demo/nav-v3", get(demo_nav_v3)) - .route("/demo/nav-v4", get(demo_nav_v4)) .route("/static/tailwind.css", get(tailwind_css)) .route("/static/htmx.min.js", get(htmx_js)) .route("/static/htmx-ext-sse.js", get(htmx_sse_js)); @@ -320,6 +310,7 @@ async fn dashboard_handler( Ok(page( "Dashboard", s.live_reload, + "/", session.as_ref().map(|e| &e.0), dashboard::page(&summary), )) @@ -333,6 +324,7 @@ async fn devices_handler( Ok(page( "Devices", s.live_reload, + "/devices", session.as_ref().map(|e| &e.0), devices_view::page(&devices), )) @@ -346,6 +338,7 @@ async fn deployments_handler( Ok(page( "Deployments", s.live_reload, + "/deployments", session.as_ref().map(|e| &e.0), deployments_view::page(&deployments), )) @@ -371,6 +364,7 @@ async fn deployment_handler( Ok(page( "Deployment", s.live_reload, + "/deployments", session.as_ref().map(|e| &e.0), deployments_view::detail(deployment, &deployment_devices), )) @@ -431,70 +425,6 @@ async fn dev_reload_sse() -> Sse) -> Markup { - let session = profile_demos::mock_session(); - page( - "Profile V1", - s.live_reload, - Some(&session), - profile_demos::v1_inline_minimal(&session), - ) -} - -async fn demo_profile_v2(State(s): State) -> Markup { - let session = profile_demos::mock_session(); - page( - "Profile V2", - s.live_reload, - Some(&session), - profile_demos::v2_compact_dropdown(&session), - ) -} - -async fn demo_profile_v3(State(s): State) -> Markup { - let session = profile_demos::mock_session(); - page( - "Profile V3", - s.live_reload, - Some(&session), - profile_demos::v3_profile_bar(&session), - ) -} - -async fn demo_profile_v4(State(s): State) -> Markup { - let session = profile_demos::mock_session(); - page( - "Profile V4", - s.live_reload, - Some(&session), - profile_demos::v4_detail_card(&session), - ) -} - -// ---- demo handlers: navigation shell options ---- - -async fn demo_nav_v1(State(_s): State) -> Markup { - let session = nav_demos::mock_session(); - nav_demos::shell_v1_sidebar(&session, nav_demos::demo_content()) -} - -async fn demo_nav_v2(State(_s): State) -> Markup { - let session = nav_demos::mock_session(); - nav_demos::shell_v2_top_dropdowns(&session, nav_demos::demo_content()) -} - -async fn demo_nav_v3(State(_s): State) -> Markup { - let session = nav_demos::mock_session(); - nav_demos::shell_v3_subnav(&session, nav_demos::demo_content()) -} - -async fn demo_nav_v4(State(_s): State) -> Markup { - let session = nav_demos::mock_session(); - nav_demos::shell_v4_pills(&session, nav_demos::demo_content()) -} - // ---- error type ---- pub struct AppError(anyhow::Error); -- 2.39.5 From 28df370a11cc2a111448ab3a12c29938da564329 Mon Sep 17 00:00:00 2001 From: Reda Tarzalt Date: Fri, 15 May 2026 10:41:30 -0400 Subject: [PATCH 09/18] change hardcoded url to env --- harmony_zitadel_auth/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/harmony_zitadel_auth/src/lib.rs b/harmony_zitadel_auth/src/lib.rs index 80230c94..29421c4d 100644 --- a/harmony_zitadel_auth/src/lib.rs +++ b/harmony_zitadel_auth/src/lib.rs @@ -173,7 +173,7 @@ pub fn build_harmony_auth_session(user: &ValidatedUser, tokens: &TokenResponse, pub fn build_logout_url(config: &ZitadelAuthConfig, id_token: &str) -> Result { let mut url = Url::parse(&config.logout_url())?; url.query_pairs_mut() - .append_pair("post_logout_redirect_uri", "http://localhost:18080/") + .append_pair("post_logout_redirect_uri", &config.redirect_uri()) .append_pair("id_token_hint", id_token); Ok(url) } -- 2.39.5 From 0caf89a36468f62257c97ee2ed5f320a95f1abbd Mon Sep 17 00:00:00 2001 From: Reda Tarzalt Date: Fri, 15 May 2026 12:18:36 -0400 Subject: [PATCH 10/18] add import --- harmony_assets/src/store/local.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/harmony_assets/src/store/local.rs b/harmony_assets/src/store/local.rs index 0a6486a4..76fded28 100644 --- a/harmony_assets/src/store/local.rs +++ b/harmony_assets/src/store/local.rs @@ -174,6 +174,7 @@ mod tests { #[cfg(feature = "reqwest")] mod download_tests { use super::*; + use crate::ChecksumAlgo; use httptest::{Expectation, Server, matchers::request, responders::*}; fn test_asset_with_url(url: &str, checksum: &str) -> Asset { -- 2.39.5 From a73511058fe103245862f39035ff6070c2690892 Mon Sep 17 00:00:00 2001 From: Reda Tarzalt Date: Fri, 15 May 2026 12:22:26 -0400 Subject: [PATCH 11/18] format code --- .../src/frontend/auth.rs | 5 ++- .../src/frontend/layout.rs | 18 +++++++-- .../src/frontend/server.rs | 15 +++----- harmony_zitadel_auth/src/lib.rs | 38 +++++++++++++------ 4 files changed, 51 insertions(+), 25 deletions(-) diff --git a/fleet/harmony-fleet-operator/src/frontend/auth.rs b/fleet/harmony-fleet-operator/src/frontend/auth.rs index 0e523b88..89c8343b 100644 --- a/fleet/harmony-fleet-operator/src/frontend/auth.rs +++ b/fleet/harmony-fleet-operator/src/frontend/auth.rs @@ -9,7 +9,10 @@ pub use harmony_zitadel_auth::{ exchange_code_for_token, validate_callback_state, validate_harmony_auth_session, validate_id_token, }; -use harmony_zitadel_auth::{ZitadelAuthConfig, build_login_attempt as build_zitadel_login_attempt, build_logout_url as build_zitadel_logout_url}; +use harmony_zitadel_auth::{ + ZitadelAuthConfig, build_login_attempt as build_zitadel_login_attempt, + build_logout_url as build_zitadel_logout_url, +}; use url::Url; const ZITADEL_BASE_ENV: &str = "FLEET_AUTH_ZITADEL_BASE"; diff --git a/fleet/harmony-fleet-operator/src/frontend/layout.rs b/fleet/harmony-fleet-operator/src/frontend/layout.rs index e69aa4a5..fcfc40d7 100644 --- a/fleet/harmony-fleet-operator/src/frontend/layout.rs +++ b/fleet/harmony-fleet-operator/src/frontend/layout.rs @@ -63,7 +63,8 @@ fn sidebar(current_path: &str, session: Option<&DashboardSession>) -> Markup { } fn nav_link(href: &str, icon: &str, label: &str, active: bool) -> Markup { - let base = "flex items-center gap-2.5 px-3 py-2 rounded-md text-sm transition-colors duration-150"; + let base = + "flex items-center gap-2.5 px-3 py-2 rounded-md text-sm transition-colors duration-150"; let cls = if active { "bg-slate-800 text-slate-100 font-medium" } else { @@ -87,9 +88,20 @@ fn user_footer(session: &DashboardSession) -> Markup { .filter_map(|w| w.chars().next()) .collect::() .to_uppercase(); - if initials.is_empty() { None } else { Some(initials) } + if initials.is_empty() { + None + } else { + Some(initials) + } }) - .unwrap_or_else(|| session.subject.chars().take(2).collect::().to_uppercase()); + .unwrap_or_else(|| { + session + .subject + .chars() + .take(2) + .collect::() + .to_uppercase() + }); let display = session .name diff --git a/fleet/harmony-fleet-operator/src/frontend/server.rs b/fleet/harmony-fleet-operator/src/frontend/server.rs index 86e02f75..92d447c7 100644 --- a/fleet/harmony-fleet-operator/src/frontend/server.rs +++ b/fleet/harmony-fleet-operator/src/frontend/server.rs @@ -22,12 +22,13 @@ use tokio_stream::StreamExt; use tokio_stream::wrappers::IntervalStream; use super::assets::{HTMX_JS, HTMX_SSE_JS, TAILWIND_CSS}; -use super::auth::{LoginAttemptCookie}; +use super::auth::LoginAttemptCookie; use super::layout::page; use super::views::{dashboard, deployments as deployments_view, devices as devices_view}; use crate::frontend::auth::{self, AuthCallbackQuery, DashboardSession, RawAuthCallbackQuery}; use crate::frontend::auth::{ - build_dashboard_session, exchange_dashboard_code_for_token, validate_dashboard_id_token, build_logout_url + build_dashboard_session, build_logout_url, exchange_dashboard_code_for_token, + validate_dashboard_id_token, }; use crate::service::FleetService; @@ -158,18 +159,14 @@ fn is_sse_request(req: &Request) -> bool { } async fn logout_handler(jar: PrivateCookieJar) -> Result { - let id_token = jar.get( - DASHBOARD_SESSION_COOKIE) + let id_token = jar + .get(DASHBOARD_SESSION_COOKIE) .map(|c| serde_json::from_str::(c.value())) .and_then(|r| r.ok()) .map(|s| s.id_token) .unwrap_or_default(); - let jar = jar.remove( - Cookie::build(DASHBOARD_SESSION_COOKIE) - .path("/") - .build() - ); + let jar = jar.remove(Cookie::build(DASHBOARD_SESSION_COOKIE).path("/").build()); let logout_url = build_logout_url(id_token.as_str())?; Ok((jar, Redirect::to(logout_url.as_str()))) diff --git a/harmony_zitadel_auth/src/lib.rs b/harmony_zitadel_auth/src/lib.rs index 29421c4d..15ae7967 100644 --- a/harmony_zitadel_auth/src/lib.rs +++ b/harmony_zitadel_auth/src/lib.rs @@ -88,12 +88,24 @@ impl From<&LoginAttempt> for LoginAttemptCookie { } impl ZitadelAuthConfig { - pub fn issuer_url(&self) -> String { self.zitadel_base.clone() } - pub fn authorize_url(&self) -> String { format!("{}/oauth/v2/authorize", self.zitadel_base) } - pub fn token_url(&self) -> String { format!("{}/oauth/v2/token", self.zitadel_base) } - pub fn logout_url(&self) -> String { format!("{}/oidc/v1/end_session", self.zitadel_base) } - pub fn redirect_uri(&self) -> String { format!("{}/auth/callback", self.base_url) } - pub fn logout_redirect_uri(&self) -> String { format!("{}/", self.base_url) } + pub fn issuer_url(&self) -> String { + self.zitadel_base.clone() + } + pub fn authorize_url(&self) -> String { + format!("{}/oauth/v2/authorize", self.zitadel_base) + } + pub fn token_url(&self) -> String { + format!("{}/oauth/v2/token", self.zitadel_base) + } + pub fn logout_url(&self) -> String { + format!("{}/oidc/v1/end_session", self.zitadel_base) + } + pub fn redirect_uri(&self) -> String { + format!("{}/auth/callback", self.base_url) + } + pub fn logout_redirect_uri(&self) -> String { + format!("{}/", self.base_url) + } } impl TryFrom for AuthCallbackQuery { @@ -128,11 +140,9 @@ pub async fn validate_id_token( http_client: &reqwest::Client, config: &ZitadelAuthConfig, ) -> anyhow::Result { - let provider_metadata = CoreProviderMetadata::discover_async( - IssuerUrl::new(config.issuer_url())?, - http_client, - ) - .await?; + let provider_metadata = + CoreProviderMetadata::discover_async(IssuerUrl::new(config.issuer_url())?, http_client) + .await?; let client = CoreClient::from_provider_metadata( provider_metadata, @@ -160,7 +170,11 @@ pub async fn validate_id_token( }) } -pub fn build_harmony_auth_session(user: &ValidatedUser, tokens: &TokenResponse, ttl: Duration) -> HarmonyAuthSession { +pub fn build_harmony_auth_session( + user: &ValidatedUser, + tokens: &TokenResponse, + ttl: Duration, +) -> HarmonyAuthSession { HarmonyAuthSession { subject: user.subject.clone(), email: user.email.clone(), -- 2.39.5 From 3eba221e13363dbb20d05b1b8f5a6af1db326ccf Mon Sep 17 00:00:00 2001 From: Reda Tarzalt Date: Fri, 15 May 2026 12:30:19 -0400 Subject: [PATCH 12/18] disable logs for ci --- .cargo/config.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.cargo/config.toml b/.cargo/config.toml index a0b2a08a..d8baa144 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -6,3 +6,6 @@ rustflags = ["-C", "link-arg=-Wl,--stack,8000000"] [target.aarch64-unknown-linux-gnu] linker = "aarch64-linux-gnu-gcc" + +[profile.test] +debug = 0 -- 2.39.5 From 2d837b5ff88c8d6fcdbc8b002cf83c1ffd785f85 Mon Sep 17 00:00:00 2001 From: Reda Tarzalt Date: Fri, 15 May 2026 12:36:18 -0400 Subject: [PATCH 13/18] install k3d before check --- .gitea/workflows/check.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitea/workflows/check.yml b/.gitea/workflows/check.yml index 508ebe7a..e81b6f47 100644 --- a/.gitea/workflows/check.yml +++ b/.gitea/workflows/check.yml @@ -14,5 +14,8 @@ jobs: - name: Checkout code uses: actions/checkout@v4 + - name: Install k3d + run: curl -s https://raw.githubusercontent.com/k3d-io/k3d/main/install.sh | bash + - name: Run check script run: bash build/check.sh -- 2.39.5 From 17ae1ee6981ce5d5913c1405409d0fc2bed76ce1 Mon Sep 17 00:00:00 2001 From: Reda Tarzalt Date: Fri, 15 May 2026 12:40:46 -0400 Subject: [PATCH 14/18] remove k3d installation and ignore test in ci --- .gitea/workflows/check.yml | 3 --- examples/fleet_auth_callout/tests/security_model.rs | 1 + 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/.gitea/workflows/check.yml b/.gitea/workflows/check.yml index e81b6f47..508ebe7a 100644 --- a/.gitea/workflows/check.yml +++ b/.gitea/workflows/check.yml @@ -14,8 +14,5 @@ jobs: - name: Checkout code uses: actions/checkout@v4 - - name: Install k3d - run: curl -s https://raw.githubusercontent.com/k3d-io/k3d/main/install.sh | bash - - name: Run check script run: bash build/check.sh diff --git a/examples/fleet_auth_callout/tests/security_model.rs b/examples/fleet_auth_callout/tests/security_model.rs index 9b1d05c8..4a3ee07d 100644 --- a/examples/fleet_auth_callout/tests/security_model.rs +++ b/examples/fleet_auth_callout/tests/security_model.rs @@ -59,6 +59,7 @@ async fn connect_with_role(stack: &StackHandles, key_json: &str) -> Result Result<()> { let _ = tracing_subscriber::fmt().with_env_filter("info").try_init(); let stack = shared_stack().await?; -- 2.39.5 From 1087717295c697ebb2d7a62e918022d737e30b62 Mon Sep 17 00:00:00 2001 From: Reda Tarzalt Date: Fri, 15 May 2026 12:45:29 -0400 Subject: [PATCH 15/18] ignore tests --- examples/fleet_auth_callout/tests/security_model.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/examples/fleet_auth_callout/tests/security_model.rs b/examples/fleet_auth_callout/tests/security_model.rs index 4a3ee07d..80a54730 100644 --- a/examples/fleet_auth_callout/tests/security_model.rs +++ b/examples/fleet_auth_callout/tests/security_model.rs @@ -85,6 +85,7 @@ async fn admin_can_read_any_device_subject() -> Result<()> { } #[tokio::test] +#[ignore = "requires k3d + docker environment"] async fn device_can_only_access_own_subjects() -> Result<()> { let _ = tracing_subscriber::fmt().with_env_filter("info").try_init(); let stack = shared_stack().await?; @@ -115,6 +116,7 @@ async fn device_can_only_access_own_subjects() -> Result<()> { } #[tokio::test] +#[ignore = "requires k3d + docker environment"] async fn unknown_role_is_rejected() -> Result<()> { let _ = tracing_subscriber::fmt().with_env_filter("info").try_init(); let stack = shared_stack().await?; -- 2.39.5 From 3d2a5b21cbabd9c6ed638f0f8e4e56597ecf4f9f Mon Sep 17 00:00:00 2001 From: Reda Tarzalt Date: Sat, 16 May 2026 15:21:49 -0400 Subject: [PATCH 16/18] move cookie mngmt to lib --- Cargo.lock | 2 + fleet/harmony-fleet-operator/Cargo.toml | 2 +- .../src/frontend/auth.rs | 68 +------- .../src/frontend/server.rs | 131 +------------- fleet/harmony-fleet-operator/src/main.rs | 30 +--- harmony_zitadel_auth/Cargo.toml | 7 +- harmony_zitadel_auth/src/lib.rs | 160 +++++++++++++++++- 7 files changed, 179 insertions(+), 221 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8a2ff872..8330a0b7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4457,6 +4457,8 @@ name = "harmony_zitadel_auth" version = "0.1.0" dependencies = [ "anyhow", + "axum", + "axum-extra", "base64 0.22.1", "chrono", "openidconnect", diff --git a/fleet/harmony-fleet-operator/Cargo.toml b/fleet/harmony-fleet-operator/Cargo.toml index 09488d25..f272a067 100644 --- a/fleet/harmony-fleet-operator/Cargo.toml +++ b/fleet/harmony-fleet-operator/Cargo.toml @@ -11,7 +11,7 @@ default = [] # build time when the standalone `tailwindcss` CLI is on PATH; otherwise # the bundled CSS is empty and `--css-from ` must be used at runtime # (the sidecar-watch dev workflow does this). -web-frontend = ["dep:axum", "dep:axum-extra", "dep:maud", "dep:tokio-stream"] +web-frontend = ["dep:axum", "dep:axum-extra", "dep:maud", "dep:tokio-stream", "harmony_zitadel_auth/axum"] [dependencies] harmony = { path = "../../harmony", features = ["podman"] } diff --git a/fleet/harmony-fleet-operator/src/frontend/auth.rs b/fleet/harmony-fleet-operator/src/frontend/auth.rs index 89c8343b..662bfc6a 100644 --- a/fleet/harmony-fleet-operator/src/frontend/auth.rs +++ b/fleet/harmony-fleet-operator/src/frontend/auth.rs @@ -1,66 +1,8 @@ -use std::env; - -use anyhow::Result; -use chrono::Duration; - pub use harmony_zitadel_auth::{ - AuthCallbackQuery, HarmonyAuthSession as DashboardSession, LoginAttempt, LoginAttemptCookie, - RawAuthCallbackQuery, TokenResponse, ValidatedUser, build_harmony_auth_session, - exchange_code_for_token, validate_callback_state, validate_harmony_auth_session, - validate_id_token, + HarmonyAuthSession as DashboardSession, validate_harmony_auth_session, }; -use harmony_zitadel_auth::{ - ZitadelAuthConfig, build_login_attempt as build_zitadel_login_attempt, - build_logout_url as build_zitadel_logout_url, + +pub use harmony_zitadel_auth::axum_login_flow::{ + HARMONY_SESSION_COOKIE as DASHBOARD_SESSION_COOKIE, callback_handler, login_handler, + logout_handler, }; -use url::Url; - -const ZITADEL_BASE_ENV: &str = "FLEET_AUTH_ZITADEL_BASE"; -const BASE_URL_ENV: &str = "BASE_URL"; -const CLIENT_ID_ENV: &str = "FLEET_AUTH_CLIENT_ID"; -const SCOPE_ENV: &str = "FLEET_AUTH_SCOPE"; -const TRUSTED_AUDIENCES_ENV: &str = "FLEET_AUTH_TRUSTED_AUDIENCES"; - -pub fn build_login_attempt() -> Result { - build_zitadel_login_attempt(&config()?) -} - -pub fn build_dashboard_session(user: &ValidatedUser, tokens: &TokenResponse) -> DashboardSession { - build_harmony_auth_session(user, tokens, Duration::hours(8)) -} - -pub fn build_logout_url(id_token: &str) -> Result { - build_zitadel_logout_url(&config()?, id_token) -} - -pub async fn exchange_dashboard_code_for_token( - client: &reqwest::Client, - pkce_code_verifier: &str, - code: &str, -) -> Result { - exchange_code_for_token(client, &config()?, pkce_code_verifier, code).await -} - -pub async fn validate_dashboard_id_token( - id_token: &str, - http_client: &reqwest::Client, -) -> Result { - validate_id_token(id_token, http_client, &config()?).await -} - -pub fn config() -> Result { - Ok(ZitadelAuthConfig { - zitadel_base: required_env(ZITADEL_BASE_ENV)?, - base_url: required_env(BASE_URL_ENV)?, - client_id: required_env(CLIENT_ID_ENV)?, - scope: required_env(SCOPE_ENV)?, - trusted_audiences: required_env(TRUSTED_AUDIENCES_ENV)? - .split(',') - .map(str::to_string) - .collect(), - }) -} - -fn required_env(name: &str) -> Result { - env::var(name).map_err(|_| anyhow::anyhow!("missing required environment variable {name}")) -} diff --git a/fleet/harmony-fleet-operator/src/frontend/server.rs b/fleet/harmony-fleet-operator/src/frontend/server.rs index 92d447c7..a62d13b2 100644 --- a/fleet/harmony-fleet-operator/src/frontend/server.rs +++ b/fleet/harmony-fleet-operator/src/frontend/server.rs @@ -7,36 +7,27 @@ use std::time::Duration; use anyhow::Result; use axum::Router; use axum::body::Body; -use axum::extract::{Extension, FromRef, Path, Query, State}; +use axum::extract::{Extension, FromRef, Path, State}; use axum::http::Request; use axum::http::{StatusCode, header}; use axum::middleware::{self, Next}; use axum::response::sse::{Event, KeepAlive, Sse}; use axum::response::{IntoResponse, Redirect, Response}; use axum::routing::{get, post}; -use axum_extra::extract::cookie::{Cookie, Key, PrivateCookieJar, SameSite}; -use base64::Engine; -use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use axum_extra::extract::cookie::{Cookie, Key, PrivateCookieJar}; use maud::Markup; use tokio_stream::StreamExt; use tokio_stream::wrappers::IntervalStream; use super::assets::{HTMX_JS, HTMX_SSE_JS, TAILWIND_CSS}; -use super::auth::LoginAttemptCookie; use super::layout::page; use super::views::{dashboard, deployments as deployments_view, devices as devices_view}; -use crate::frontend::auth::{self, AuthCallbackQuery, DashboardSession, RawAuthCallbackQuery}; -use crate::frontend::auth::{ - build_dashboard_session, build_logout_url, exchange_dashboard_code_for_token, - validate_dashboard_id_token, -}; +use crate::frontend::auth::{self, DASHBOARD_SESSION_COOKIE, DashboardSession}; use crate::service::FleetService; /// Default high port — keeps clear of NATS (4222), k8s API (6443), /// and common metrics/webhook ports (8080/9090/9443). pub const DEFAULT_PORT: u16 = 18080; -const LOGIN_ATTEMPT_COOKIE: &str = "harmony_fleet_login_attempt"; -const DASHBOARD_SESSION_COOKIE: &str = "harmony_fleet_session"; #[derive(Clone)] pub struct AppState { @@ -78,15 +69,15 @@ impl Config { pub fn router(state: AppState) -> Router { let public_routes = Router::new() - .route("/login", get(login_handler)) - .route("/auth/callback", get(auth_callback_handler)) + .route("/login", get(auth::login_handler)) + .route("/auth/callback", get(auth::callback_handler)) .route("/static/tailwind.css", get(tailwind_css)) .route("/static/htmx.min.js", get(htmx_js)) .route("/static/htmx-ext-sse.js", get(htmx_sse_js)); let private_routes = Router::new() .route("/", get(dashboard_handler)) - .route("/logout", get(logout_handler)) + .route("/logout", get(auth::logout_handler)) .route("/devices", get(devices_handler)) .route("/devices/{id}/blacklist", post(blacklist_handler)) .route("/deployments", get(deployments_handler)) @@ -158,116 +149,6 @@ fn is_sse_request(req: &Request) -> bool { .is_some_and(|value| value.contains("text/event-stream")) } -async fn logout_handler(jar: PrivateCookieJar) -> Result { - let id_token = jar - .get(DASHBOARD_SESSION_COOKIE) - .map(|c| serde_json::from_str::(c.value())) - .and_then(|r| r.ok()) - .map(|s| s.id_token) - .unwrap_or_default(); - - let jar = jar.remove(Cookie::build(DASHBOARD_SESSION_COOKIE).path("/").build()); - - let logout_url = build_logout_url(id_token.as_str())?; - Ok((jar, Redirect::to(logout_url.as_str()))) -} - -async fn login_handler(jar: PrivateCookieJar) -> Result { - let attempt = auth::build_login_attempt()?; - let cookie_payload = LoginAttemptCookie::from(&attempt); - let cookie_value = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&cookie_payload)?); - - let jar = jar.add( - Cookie::build((LOGIN_ATTEMPT_COOKIE, cookie_value)) - .http_only(true) - .same_site(SameSite::Lax) - .path("/") - .build(), - ); - - tracing::debug!("created OIDC login attempt"); - - Ok((jar, Redirect::temporary(&attempt.authorize_url))) -} - -async fn auth_callback_handler( - jar: PrivateCookieJar, - Query(raw): Query, -) -> Result { - let query = AuthCallbackQuery::try_from(raw); - - match query { - Ok(AuthCallbackQuery::Success { code, state }) => { - let attempt = read_login_attempt_cookie(&jar)?; - let jar = jar.remove(Cookie::from(LOGIN_ATTEMPT_COOKIE)); - - auth::validate_callback_state(&attempt, &state)?; - - let client = reqwest::Client::new(); - let tokens = - exchange_dashboard_code_for_token(&client, &attempt.pkce_code_verifier, &code) - .await?; - let user = validate_dashboard_id_token(&tokens.id_token, &client).await?; - let session = build_dashboard_session(&user, &tokens); - - let jar = jar.add( - Cookie::build((DASHBOARD_SESSION_COOKIE, serde_json::to_string(&session)?)) - .http_only(true) - .same_site(SameSite::Lax) - .path("/") - .build(), - ); - - tracing::debug!("ID token validated; dashboard session created"); - - Ok((jar, Redirect::to("/")).into_response()) - } - - Ok(AuthCallbackQuery::Failure { - error, - error_description, - }) => { - tracing::warn!(%error, "SSO callback returned an error"); - - Ok(maud::html! { - pre { - "SSO login failed\n" - "Error: " (error) "\n" - "Description: " (error_description.unwrap_or_default()) "\n" - } - } - .into_response()) - } - - Err(e) => { - tracing::warn!(%e, "SSO callback query shape mismatch"); - - Ok(maud::html! { - pre { - "SSO login failed\n" - "Error: " (e.to_string()) "\n" - } - } - .into_response()) - } - } -} - -fn read_login_attempt_cookie(jar: &PrivateCookieJar) -> Result { - let attempt_cookie = jar - .get(LOGIN_ATTEMPT_COOKIE) - .ok_or_else(|| anyhow::anyhow!("missing login attempt cookie; start again at /login"))?; - - let decoded = URL_SAFE_NO_PAD - .decode(attempt_cookie.value()) - .map_err(|e| anyhow::anyhow!("invalid login attempt cookie encoding: {e}"))?; - - let attempt: LoginAttemptCookie = serde_json::from_slice(&decoded) - .map_err(|e| anyhow::anyhow!("invalid login attempt cookie payload: {e}"))?; - - Ok(attempt) -} - pub async fn run(cfg: Config) -> Result<()> { let addr = cfg.addr; let listener = tokio::net::TcpListener::bind(addr).await?; diff --git a/fleet/harmony-fleet-operator/src/main.rs b/fleet/harmony-fleet-operator/src/main.rs index 4e199b2a..5d9d7b45 100644 --- a/fleet/harmony-fleet-operator/src/main.rs +++ b/fleet/harmony-fleet-operator/src/main.rs @@ -172,7 +172,7 @@ async fn serve_web( ); }; - let cookie_key = load_cookie_key()?; + let cookie_key = harmony_zitadel_auth::cookie_key_from_env(); frontend::server::run( Config::new(AppState { @@ -186,34 +186,6 @@ async fn serve_web( .await } -#[cfg(feature = "web-frontend")] -fn load_cookie_key() -> Result { - use axum_extra::extract::cookie::Key; - use base64::Engine; - use base64::engine::general_purpose::STANDARD; - - match std::env::var("FLEET_OPERATOR_COOKIE_KEY_B64") { - Ok(encoded) => { - let bytes = STANDARD - .decode(encoded.trim()) - .context("FLEET_OPERATOR_COOKIE_KEY_B64 must be standard base64")?; - if bytes.len() < 64 { - anyhow::bail!( - "FLEET_OPERATOR_COOKIE_KEY_B64 must decode to at least 64 bytes for private cookies" - ); - } - Ok(Key::from(&bytes)) - } - Err(std::env::VarError::NotPresent) => { - tracing::warn!( - "FLEET_OPERATOR_COOKIE_KEY_B64 is not set; generating an ephemeral development cookie key; sessions and in-flight login callbacks will not survive restart" - ); - Ok(Key::generate()) - } - Err(e) => Err(e).context("failed to read FLEET_OPERATOR_COOKIE_KEY_B64"), - } -} - async fn run(nats_url: &str, bucket: &str, credentials_toml: &str) -> Result<()> { let nats = connect_with_retry(nats_url, credentials_toml).await?; tracing::info!(url = %nats_url, "connected to NATS"); diff --git a/harmony_zitadel_auth/Cargo.toml b/harmony_zitadel_auth/Cargo.toml index 25ef962b..940ac84f 100644 --- a/harmony_zitadel_auth/Cargo.toml +++ b/harmony_zitadel_auth/Cargo.toml @@ -5,6 +5,10 @@ version.workspace = true readme.workspace = true license.workspace = true +[features] +default = [] +axum = ["dep:axum", "dep:axum-extra"] + [dependencies] anyhow.workspace = true base64.workspace = true @@ -17,4 +21,5 @@ sha2 = "0.10" url.workspace = true openidconnect = { version = "4", default-features = false, features = ["reqwest", "rustls-tls"] } - +axum = { version = "0.8", optional = true } +axum-extra = { version = "0.10", features = ["cookie", "cookie-private"], optional = true } diff --git a/harmony_zitadel_auth/src/lib.rs b/harmony_zitadel_auth/src/lib.rs index 15ae7967..add39c5a 100644 --- a/harmony_zitadel_auth/src/lib.rs +++ b/harmony_zitadel_auth/src/lib.rs @@ -1,9 +1,12 @@ +use std::env; use std::str::FromStr; use anyhow::Result; use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use chrono::{Duration, Utc}; +#[cfg(feature = "axum")] +use axum_extra::extract::cookie::Key; use openidconnect::Nonce; use openidconnect::core::{CoreClient, CoreIdToken, CoreProviderMetadata}; use openidconnect::{ClientId, IssuerUrl}; @@ -19,6 +22,7 @@ pub struct ZitadelAuthConfig { pub client_id: String, pub scope: String, pub trusted_audiences: Vec, + pub logout_redirect_uri: String, } #[derive(Debug, Clone)] @@ -104,7 +108,7 @@ impl ZitadelAuthConfig { format!("{}/auth/callback", self.base_url) } pub fn logout_redirect_uri(&self) -> String { - format!("{}/", self.base_url) + self.logout_redirect_uri.clone() } } @@ -187,7 +191,7 @@ pub fn build_harmony_auth_session( pub fn build_logout_url(config: &ZitadelAuthConfig, id_token: &str) -> Result { let mut url = Url::parse(&config.logout_url())?; url.query_pairs_mut() - .append_pair("post_logout_redirect_uri", &config.redirect_uri()) + .append_pair("post_logout_redirect_uri", &config.logout_redirect_uri()) .append_pair("id_token_hint", id_token); Ok(url) } @@ -271,6 +275,158 @@ pub fn validate_harmony_auth_session(session: &HarmonyAuthSession) -> Result<()> Ok(()) } +pub const ZITADEL_BASE_ENV: &str = "FLEET_AUTH_ZITADEL_BASE"; +pub const BASE_URL_ENV: &str = "BASE_URL"; +pub const CLIENT_ID_ENV: &str = "FLEET_AUTH_CLIENT_ID"; +pub const SCOPE_ENV: &str = "FLEET_AUTH_SCOPE"; +pub const TRUSTED_AUDIENCES_ENV: &str = "FLEET_AUTH_TRUSTED_AUDIENCES"; +pub const LOGOUT_REDIRECT_URI_ENV: &str = "FLEET_AUTH_LOGOUT_REDIRECT_URI"; +pub const COOKIE_KEY_ENV: &str = "FLEET_OPERATOR_COOKIE_KEY_B64"; + +pub fn config_from_env() -> ZitadelAuthConfig { + ZitadelAuthConfig { + zitadel_base: required_env(ZITADEL_BASE_ENV), + base_url: required_env(BASE_URL_ENV), + client_id: required_env(CLIENT_ID_ENV), + scope: required_env(SCOPE_ENV), + trusted_audiences: required_env(TRUSTED_AUDIENCES_ENV) + .split(',') + .map(str::to_string) + .collect(), + logout_redirect_uri: required_env(LOGOUT_REDIRECT_URI_ENV), + } +} + +fn required_env(name: &str) -> String { + env::var(name).unwrap_or_else(|_| panic!("missing required environment variable {name}")) +} + +#[cfg(feature = "axum")] +pub fn cookie_key_from_env() -> Key { + use base64::engine::general_purpose::STANDARD; + + let encoded = required_env(COOKIE_KEY_ENV); + let bytes = STANDARD + .decode(encoded.trim()) + .unwrap_or_else(|e| panic!("{COOKIE_KEY_ENV} must be standard base64: {e}")); + if bytes.len() < 64 { + panic!("{COOKIE_KEY_ENV} must decode to at least 64 bytes for private cookies"); + } + Key::from(&bytes) +} + +#[cfg(feature = "axum")] +pub mod axum_login_flow { + use axum::extract::Query; + use axum::http::StatusCode; + use axum::response::{IntoResponse, Redirect, Response}; + use axum_extra::extract::cookie::{Cookie, PrivateCookieJar, SameSite}; + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + + use super::*; + + pub const LOGIN_ATTEMPT_COOKIE: &str = "harmony_fleet_login_attempt"; + pub const HARMONY_SESSION_COOKIE: &str = "harmony_fleet_session"; + + pub async fn login_handler(jar: PrivateCookieJar) -> Response { + match build_login_response(jar) { + Ok(response) => response.into_response(), + Err(e) => auth_error_response(e), + } + } + + fn build_login_response(jar: PrivateCookieJar) -> Result { + let config = config_from_env(); + let attempt = build_login_attempt(&config)?; + let cookie_payload = LoginAttemptCookie::from(&attempt); + let cookie_value = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&cookie_payload)?); + + let jar = jar.add( + Cookie::build((LOGIN_ATTEMPT_COOKIE, cookie_value)) + .http_only(true) + .same_site(SameSite::Lax) + .path("/") + .build(), + ); + + Ok((jar, Redirect::temporary(&attempt.authorize_url))) + } + + pub async fn logout_handler(jar: PrivateCookieJar) -> Response { + match build_logout_response(jar) { + Ok(response) => response.into_response(), + Err(e) => auth_error_response(e), + } + } + + fn build_logout_response(jar: PrivateCookieJar) -> Result { + let id_token = jar + .get(HARMONY_SESSION_COOKIE) + .map(|c| serde_json::from_str::(c.value())) + .and_then(|r| r.ok()) + .map(|s| s.id_token) + .unwrap_or_default(); + + let jar = jar.remove(Cookie::build(HARMONY_SESSION_COOKIE).path("/").build()); + let logout_url = build_logout_url(&config_from_env(), id_token.as_str())?; + Ok((jar, Redirect::to(logout_url.as_str()))) + } + + pub async fn callback_handler( + jar: PrivateCookieJar, + Query(raw): Query, + ) -> Response { + match build_callback_response(jar, raw).await { + Ok(response) => response, + Err(e) => auth_error_response(e), + } + } + + async fn build_callback_response(jar: PrivateCookieJar, raw: RawAuthCallbackQuery) -> Result { + let config = config_from_env(); + match AuthCallbackQuery::try_from(raw)? { + AuthCallbackQuery::Success { code, state } => { + let attempt = read_login_attempt_cookie(&jar)?; + let jar = jar.remove(Cookie::from(LOGIN_ATTEMPT_COOKIE)); + validate_callback_state(&attempt, &state)?; + + let client = reqwest::Client::new(); + let tokens = exchange_code_for_token(&client, &config, &attempt.pkce_code_verifier, &code).await?; + let user = validate_id_token(&tokens.id_token, &client, &config).await?; + let session = build_harmony_auth_session(&user, &tokens, Duration::hours(8)); + + let jar = jar.add( + Cookie::build((HARMONY_SESSION_COOKIE, serde_json::to_string(&session)?)) + .http_only(true) + .same_site(SameSite::Lax) + .path("/") + .build(), + ); + + Ok((jar, Redirect::to("/")).into_response()) + } + AuthCallbackQuery::Failure { error, error_description } => { + anyhow::bail!("SSO callback returned an error: {error} {}", error_description.unwrap_or_default()) + } + } + } + + fn auth_error_response(e: anyhow::Error) -> Response { + (StatusCode::BAD_REQUEST, format!("SSO login failed\nError: {e}\n")).into_response() + } + + pub fn read_login_attempt_cookie(jar: &PrivateCookieJar) -> Result { + let attempt_cookie = jar + .get(LOGIN_ATTEMPT_COOKIE) + .ok_or_else(|| anyhow::anyhow!("missing login attempt cookie; start again at /login"))?; + let bytes = URL_SAFE_NO_PAD + .decode(attempt_cookie.value()) + .map_err(|e| anyhow::anyhow!("invalid login attempt cookie encoding: {e}"))?; + serde_json::from_slice::(&bytes) + .map_err(|e| anyhow::anyhow!("invalid login attempt cookie payload: {e}")) + } +} + #[cfg(test)] mod tests { use super::*; -- 2.39.5 From 682b3f6c9c0894dc32ff0bac7c8e2e9f739748f8 Mon Sep 17 00:00:00 2001 From: Reda Tarzalt Date: Tue, 19 May 2026 13:58:43 -0400 Subject: [PATCH 17/18] remove private jar and add cookie validation --- Cargo.lock | 4 + .../src/frontend/auth.rs | 4 +- harmony_zitadel_auth/Cargo.toml | 4 + harmony_zitadel_auth/src/lib.rs | 354 ++++++++++++++---- 4 files changed, 290 insertions(+), 76 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8330a0b7..4a9ae9ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4461,12 +4461,16 @@ dependencies = [ "axum-extra", "base64 0.22.1", "chrono", + "jsonwebtoken", "openidconnect", "rand 0.9.2", "reqwest 0.12.28", "serde", "serde_json", "sha2", + "time", + "tokio", + "tracing", "url", ] diff --git a/fleet/harmony-fleet-operator/src/frontend/auth.rs b/fleet/harmony-fleet-operator/src/frontend/auth.rs index 662bfc6a..e3d62567 100644 --- a/fleet/harmony-fleet-operator/src/frontend/auth.rs +++ b/fleet/harmony-fleet-operator/src/frontend/auth.rs @@ -1,6 +1,4 @@ -pub use harmony_zitadel_auth::{ - HarmonyAuthSession as DashboardSession, validate_harmony_auth_session, -}; +pub use harmony_zitadel_auth::{JwksCache, VerifiedSession as DashboardSession}; pub use harmony_zitadel_auth::axum_login_flow::{ HARMONY_SESSION_COOKIE as DASHBOARD_SESSION_COOKIE, callback_handler, login_handler, diff --git a/harmony_zitadel_auth/Cargo.toml b/harmony_zitadel_auth/Cargo.toml index 940ac84f..781b1670 100644 --- a/harmony_zitadel_auth/Cargo.toml +++ b/harmony_zitadel_auth/Cargo.toml @@ -19,7 +19,11 @@ serde.workspace = true serde_json.workspace = true sha2 = "0.10" url.workspace = true +tokio = { workspace = true, features = ["sync", "time"] } +time = "0.3" +tracing = { workspace = true } +jsonwebtoken = "9" openidconnect = { version = "4", default-features = false, features = ["reqwest", "rustls-tls"] } axum = { version = "0.8", optional = true } axum-extra = { version = "0.10", features = ["cookie", "cookie-private"], optional = true } diff --git a/harmony_zitadel_auth/src/lib.rs b/harmony_zitadel_auth/src/lib.rs index add39c5a..c80edc73 100644 --- a/harmony_zitadel_auth/src/lib.rs +++ b/harmony_zitadel_auth/src/lib.rs @@ -1,10 +1,8 @@ -use std::env; -use std::str::FromStr; +use std::sync::Arc; use anyhow::Result; use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; -use chrono::{Duration, Utc}; #[cfg(feature = "axum")] use axum_extra::extract::cookie::Key; use openidconnect::Nonce; @@ -25,6 +23,15 @@ pub struct ZitadelAuthConfig { pub logout_redirect_uri: String, } +/// Outcome of verifying a session cookie JWT on each request. +#[derive(Debug, Clone)] +pub struct VerifiedSession { + pub subject: String, + pub email: Option, + pub name: Option, + pub expires_at: i64, +} + #[derive(Debug, Clone)] pub struct ValidatedUser { pub subject: String, @@ -53,15 +60,6 @@ pub struct LoginAttemptCookie { pub pkce_code_verifier: String, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct HarmonyAuthSession { - pub subject: String, - pub email: Option, - pub name: Option, - pub expires_at: i64, - pub id_token: String, -} - #[derive(Debug, Deserialize)] pub struct RawAuthCallbackQuery { pub code: Option, @@ -110,6 +108,10 @@ impl ZitadelAuthConfig { pub fn logout_redirect_uri(&self) -> String { self.logout_redirect_uri.clone() } + /// Whether to set the `Secure` flag on cookies. True when `base_url` is HTTPS. + pub fn use_secure_cookies(&self) -> bool { + self.base_url.starts_with("https://") + } } impl TryFrom for AuthCallbackQuery { @@ -139,6 +141,211 @@ impl TryFrom for AuthCallbackQuery { } } +// ── JWKS cache ───────────────────────────────────────────────────────── + +struct JwksCacheInner { + set: jsonwebtoken::jwk::JwkSet, + last_forced_refresh: Option, +} + +/// Cached Zitadel JWKS for per-request JWT verification. +/// +/// `Clone` is cheap — the inner state is `Arc`-wrapped. +#[derive(Clone)] +pub struct JwksCache { + inner: Arc>, + jwks_uri: Arc, + http: reqwest::Client, +} + +impl JwksCache { + /// Fetch the JWKS via OIDC discovery and build the cache. + pub async fn new(issuer_url: &str, http: reqwest::Client) -> Result { + let jwks_uri = discover_jwks_uri(issuer_url, &http).await?; + let set = fetch_jwks(&jwks_uri, &http).await?; + tracing::debug!(%jwks_uri, keys = set.keys.len(), "JWKS loaded"); + Ok(Self { + inner: Arc::new(tokio::sync::RwLock::new(JwksCacheInner { + set, + last_forced_refresh: None, + })), + jwks_uri: jwks_uri.into(), + http, + }) + } + + /// Spawn a background task that refreshes the JWKS on the given `interval`. + /// + /// On failure the stale keys are kept and a warning is logged — a Zitadel + /// blip must not log everyone out. + pub fn spawn_background_refresh(&self, interval: std::time::Duration) { + let cache = self.clone(); + tokio::spawn(async move { + let mut ticker = tokio::time::interval(interval); + ticker.tick().await; // skip the first immediate tick + loop { + ticker.tick().await; + match fetch_jwks(&cache.jwks_uri, &cache.http).await { + Ok(set) => { + cache.inner.write().await.set = set; + tracing::debug!("JWKS background refresh succeeded"); + } + Err(e) => tracing::warn!(error = %e, "JWKS background refresh failed; keeping stale keys"), + } + } + }); + } + + /// Verify a raw JWT string and return the validated session claims. + /// + /// On unknown `kid`, performs one forced JWKS refresh (rate-limited to + /// once per 60 s) before giving up, to handle key rotation gracefully. + pub async fn verify(&self, token: &str, config: &ZitadelAuthConfig) -> Result { + use jsonwebtoken::decode_header; + + let header = decode_header(token).map_err(|e| anyhow::anyhow!("invalid JWT header: {e}"))?; + let kid = header.kid.as_deref().unwrap_or(""); + + // Fast path: verify with cached keys (read lock, no await while held). + { + let inner = self.inner.read().await; + if let Some(result) = try_verify_with_set(token, &inner.set, kid, config) { + return result; + } + } + + // Slow path: kid not found — maybe Zitadel rotated keys. + let should_refresh = { + let inner = self.inner.read().await; + inner + .last_forced_refresh + .map(|t| t.elapsed() > std::time::Duration::from_secs(60)) + .unwrap_or(true) + }; + + if should_refresh { + match fetch_jwks(&self.jwks_uri, &self.http).await { + Ok(new_set) => { + let mut inner = self.inner.write().await; + inner.set = new_set; + inner.last_forced_refresh = Some(std::time::Instant::now()); + if let Some(result) = try_verify_with_set(token, &inner.set, kid, config) { + return result; + } + } + Err(e) => tracing::warn!(error = %e, "JWKS forced refresh failed"), + } + } + + anyhow::bail!("unknown JWT signing key (kid={kid:?})") + } +} + +fn try_verify_with_set( + token: &str, + set: &jsonwebtoken::jwk::JwkSet, + kid: &str, + config: &ZitadelAuthConfig, +) -> Option> { + let jwk = if kid.is_empty() { + set.keys.first()? + } else { + set.keys.iter().find(|k| k.common.key_id.as_deref() == Some(kid))? + }; + Some(verify_with_jwk(token, jwk, config)) +} + +fn verify_with_jwk( + token: &str, + jwk: &jsonwebtoken::jwk::Jwk, + config: &ZitadelAuthConfig, +) -> Result { + use jsonwebtoken::jwk::AlgorithmParameters; + use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode}; + + let decoding_key = DecodingKey::from_jwk(jwk).map_err(|e| anyhow::anyhow!("invalid JWK: {e}"))?; + + // Algorithm is determined from the JWK (server-controlled), not the token header, + // to avoid algorithm-confusion attacks. + let alg = match &jwk.algorithm { + AlgorithmParameters::RSA(_) => Algorithm::RS256, + AlgorithmParameters::EllipticCurve(ec) => { + use jsonwebtoken::jwk::EllipticCurve; + match ec.curve { + EllipticCurve::P256 => Algorithm::ES256, + EllipticCurve::P384 => Algorithm::ES384, + ref c => anyhow::bail!("unsupported elliptic curve: {c:?}"), + } + } + other => anyhow::bail!("unsupported JWK key type: {other:?}"), + }; + + let mut validation = Validation::new(alg); + validation.set_audience(&config.trusted_audiences); + validation.set_issuer(&[&config.zitadel_base]); + + #[derive(Deserialize)] + struct Claims { + sub: String, + exp: i64, + email: Option, + name: Option, + } + + let claims = decode::(token, &decoding_key, &validation) + .map_err(|e| anyhow::anyhow!("JWT verification failed: {e}"))? + .claims; + + Ok(VerifiedSession { + subject: claims.sub, + email: claims.email, + name: claims.name, + expires_at: claims.exp, + }) +} + +async fn discover_jwks_uri(issuer_url: &str, http: &reqwest::Client) -> Result { + let url = format!( + "{}/.well-known/openid-configuration", + issuer_url.trim_end_matches('/') + ); + #[derive(Deserialize)] + struct Discovery { + jwks_uri: String, + } + let disc: Discovery = http + .get(&url) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(disc.jwks_uri) +} + +async fn fetch_jwks(jwks_uri: &str, http: &reqwest::Client) -> Result { + let set = http + .get(jwks_uri) + .send() + .await? + .error_for_status()? + .json::() + .await?; + Ok(set) +} + +/// Decode the JWT payload (without verification) to extract `exp` for cookie `Max-Age`. +fn jwt_exp(token: &str) -> Option { + let payload = token.split('.').nth(1)?; + let bytes = URL_SAFE_NO_PAD.decode(payload).ok()?; + let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?; + value.get("exp")?.as_i64() +} + +// ── OIDC login helpers ───────────────────────────────────────────────── + +/// Full OIDC-compliant id_token validation. Used once per login callback; not +/// the per-request hot path (use `JwksCache::verify` for that). pub async fn validate_id_token( id_token: &str, http_client: &reqwest::Client, @@ -174,19 +381,7 @@ pub async fn validate_id_token( }) } -pub fn build_harmony_auth_session( - user: &ValidatedUser, - tokens: &TokenResponse, - ttl: Duration, -) -> HarmonyAuthSession { - HarmonyAuthSession { - subject: user.subject.clone(), - email: user.email.clone(), - name: user.name.clone(), - expires_at: (Utc::now() + ttl).timestamp(), - id_token: tokens.id_token.clone(), - } -} +use std::str::FromStr; pub fn build_logout_url(config: &ZitadelAuthConfig, id_token: &str) -> Result { let mut url = Url::parse(&config.logout_url())?; @@ -267,14 +462,6 @@ pub fn validate_callback_state(attempt: &LoginAttemptCookie, returned_state: &st Ok(()) } -pub fn validate_harmony_auth_session(session: &HarmonyAuthSession) -> Result<()> { - if session.expires_at <= Utc::now().timestamp() { - anyhow::bail!("auth session expired"); - } - - Ok(()) -} - pub const ZITADEL_BASE_ENV: &str = "FLEET_AUTH_ZITADEL_BASE"; pub const BASE_URL_ENV: &str = "BASE_URL"; pub const CLIENT_ID_ENV: &str = "FLEET_AUTH_CLIENT_ID"; @@ -298,7 +485,7 @@ pub fn config_from_env() -> ZitadelAuthConfig { } fn required_env(name: &str) -> String { - env::var(name).unwrap_or_else(|_| panic!("missing required environment variable {name}")) + std::env::var(name).unwrap_or_else(|_| panic!("missing required environment variable {name}")) } #[cfg(feature = "axum")] @@ -317,10 +504,10 @@ pub fn cookie_key_from_env() -> Key { #[cfg(feature = "axum")] pub mod axum_login_flow { - use axum::extract::Query; + use axum::extract::{Query, State}; use axum::http::StatusCode; use axum::response::{IntoResponse, Redirect, Response}; - use axum_extra::extract::cookie::{Cookie, PrivateCookieJar, SameSite}; + use axum_extra::extract::cookie::{Cookie, CookieJar, PrivateCookieJar, SameSite}; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use super::*; @@ -328,82 +515,103 @@ pub mod axum_login_flow { pub const LOGIN_ATTEMPT_COOKIE: &str = "harmony_fleet_login_attempt"; pub const HARMONY_SESSION_COOKIE: &str = "harmony_fleet_session"; - pub async fn login_handler(jar: PrivateCookieJar) -> Response { - match build_login_response(jar) { + /// Plain cookie jar for session (raw JWT, no encryption needed — JWT is signed by Zitadel). + /// Private cookie jar is kept only for the short-lived login-attempt cookie (PKCE verifier). + pub async fn login_handler( + jar: PrivateCookieJar, + State(config): State, + ) -> Response { + match build_login_response(jar, &config) { Ok(response) => response.into_response(), Err(e) => auth_error_response(e), } } - fn build_login_response(jar: PrivateCookieJar) -> Result { - let config = config_from_env(); - let attempt = build_login_attempt(&config)?; + fn build_login_response(jar: PrivateCookieJar, config: &ZitadelAuthConfig) -> Result { + let attempt = build_login_attempt(config)?; let cookie_payload = LoginAttemptCookie::from(&attempt); let cookie_value = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&cookie_payload)?); - let jar = jar.add( - Cookie::build((LOGIN_ATTEMPT_COOKIE, cookie_value)) - .http_only(true) - .same_site(SameSite::Lax) - .path("/") - .build(), - ); + let mut builder = Cookie::build((LOGIN_ATTEMPT_COOKIE, cookie_value)) + .http_only(true) + .same_site(SameSite::Lax) + .path("/"); + if config.use_secure_cookies() { + builder = builder.secure(true); + } + let jar = jar.add(builder.build()); Ok((jar, Redirect::temporary(&attempt.authorize_url))) } - pub async fn logout_handler(jar: PrivateCookieJar) -> Response { - match build_logout_response(jar) { + pub async fn logout_handler( + session_jar: CookieJar, + State(config): State, + ) -> Response { + match build_logout_response(session_jar, &config) { Ok(response) => response.into_response(), Err(e) => auth_error_response(e), } } - fn build_logout_response(jar: PrivateCookieJar) -> Result { - let id_token = jar + fn build_logout_response(session_jar: CookieJar, config: &ZitadelAuthConfig) -> Result { + // The session cookie value IS the raw JWT (id_token), used as the Zitadel logout hint. + let id_token = session_jar .get(HARMONY_SESSION_COOKIE) - .map(|c| serde_json::from_str::(c.value())) - .and_then(|r| r.ok()) - .map(|s| s.id_token) + .map(|c| c.value().to_string()) .unwrap_or_default(); - let jar = jar.remove(Cookie::build(HARMONY_SESSION_COOKIE).path("/").build()); - let logout_url = build_logout_url(&config_from_env(), id_token.as_str())?; - Ok((jar, Redirect::to(logout_url.as_str()))) + let session_jar = session_jar.remove(Cookie::build(HARMONY_SESSION_COOKIE).path("/").build()); + let logout_url = build_logout_url(config, &id_token)?; + Ok((session_jar, Redirect::to(logout_url.as_str()))) } pub async fn callback_handler( jar: PrivateCookieJar, + session_jar: CookieJar, + State(config): State, + State(http_client): State, Query(raw): Query, ) -> Response { - match build_callback_response(jar, raw).await { + match build_callback_response(jar, session_jar, raw, &config, &http_client).await { Ok(response) => response, Err(e) => auth_error_response(e), } } - async fn build_callback_response(jar: PrivateCookieJar, raw: RawAuthCallbackQuery) -> Result { - let config = config_from_env(); + async fn build_callback_response( + jar: PrivateCookieJar, + session_jar: CookieJar, + raw: RawAuthCallbackQuery, + config: &ZitadelAuthConfig, + http_client: &reqwest::Client, + ) -> Result { match AuthCallbackQuery::try_from(raw)? { AuthCallbackQuery::Success { code, state } => { let attempt = read_login_attempt_cookie(&jar)?; let jar = jar.remove(Cookie::from(LOGIN_ATTEMPT_COOKIE)); validate_callback_state(&attempt, &state)?; - let client = reqwest::Client::new(); - let tokens = exchange_code_for_token(&client, &config, &attempt.pkce_code_verifier, &code).await?; - let user = validate_id_token(&tokens.id_token, &client, &config).await?; - let session = build_harmony_auth_session(&user, &tokens, Duration::hours(8)); + let tokens = exchange_code_for_token(http_client, config, &attempt.pkce_code_verifier, &code).await?; + // Full OIDC verification at login time (signature + nonce + audience). + validate_id_token(&tokens.id_token, http_client, config).await?; - let jar = jar.add( - Cookie::build((HARMONY_SESSION_COOKIE, serde_json::to_string(&session)?)) - .http_only(true) - .same_site(SameSite::Lax) - .path("/") - .build(), - ); + let max_age_secs = jwt_exp(&tokens.id_token) + .map(|exp| (exp - chrono::Utc::now().timestamp()).max(0)); - Ok((jar, Redirect::to("/")).into_response()) + let mut cookie_builder = Cookie::build((HARMONY_SESSION_COOKIE, tokens.id_token)) + .http_only(true) + .same_site(SameSite::Lax) + .path("/"); + if config.use_secure_cookies() { + cookie_builder = cookie_builder.secure(true); + } + if let Some(secs) = max_age_secs { + cookie_builder = cookie_builder.max_age(time::Duration::seconds(secs)); + } + let session_jar = session_jar.add(cookie_builder.build()); + + Ok((jar, session_jar, Redirect::to("/")).into_response()) } AuthCallbackQuery::Failure { error, error_description } => { anyhow::bail!("SSO callback returned an error: {error} {}", error_description.unwrap_or_default()) -- 2.39.5 From bb19bb0255ffc9f91c294ce10fda94628fa5028a Mon Sep 17 00:00:00 2001 From: Reda Tarzalt Date: Tue, 19 May 2026 16:05:24 -0400 Subject: [PATCH 18/18] add ui redesign and update auth lib --- Cargo.lock | 1 + docs/SUMMARY.md | 1 + docs/guides/web-auth-security.md | 217 +++++ .../src/frontend/assets.rs | 1 + .../src/frontend/layout.rs | 201 +++-- .../src/frontend/server.rs | 624 +++++++++++--- .../src/frontend/views/alerts.rs | 125 +++ .../src/frontend/views/badges.rs | 91 +- .../src/frontend/views/dashboard.rs | 376 ++++++++- .../src/frontend/views/deployments.rs | 612 ++++++++++++-- .../src/frontend/views/devices.rs | 657 +++++++++++++-- .../src/frontend/views/mod.rs | 2 + .../src/frontend/views/settings.rs | 70 ++ fleet/harmony-fleet-operator/src/main.rs | 11 + .../src/service/mock.rs | 777 ++++++++++++++---- .../harmony-fleet-operator/src/service/mod.rs | 174 +++- fleet/harmony-fleet-operator/style/input.css | 102 +++ fleet/harmony-fleet-operator/vendor/app.js | 3 + harmony_zitadel_auth/Cargo.toml | 3 +- harmony_zitadel_auth/src/axum_login_flow.rs | 164 ++++ harmony_zitadel_auth/src/config.rs | 75 ++ harmony_zitadel_auth/src/jwks.rs | 210 +++++ harmony_zitadel_auth/src/lib.rs | 659 +-------------- harmony_zitadel_auth/src/login.rs | 228 +++++ harmony_zitadel_auth/src/session.rs | 21 + 25 files changed, 4241 insertions(+), 1164 deletions(-) create mode 100644 docs/guides/web-auth-security.md create mode 100644 fleet/harmony-fleet-operator/src/frontend/views/alerts.rs create mode 100644 fleet/harmony-fleet-operator/src/frontend/views/settings.rs create mode 100644 fleet/harmony-fleet-operator/vendor/app.js create mode 100644 harmony_zitadel_auth/src/axum_login_flow.rs create mode 100644 harmony_zitadel_auth/src/config.rs create mode 100644 harmony_zitadel_auth/src/jwks.rs create mode 100644 harmony_zitadel_auth/src/login.rs create mode 100644 harmony_zitadel_auth/src/session.rs diff --git a/Cargo.lock b/Cargo.lock index 4a9ae9ba..5344ac04 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4457,6 +4457,7 @@ name = "harmony_zitadel_auth" version = "0.1.0" dependencies = [ "anyhow", + "arc-swap", "axum", "axum-extra", "base64 0.22.1", diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index a61fbf68..10ae47c4 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -23,6 +23,7 @@ - [Writing a Score](./guides/writing-a-score.md) - [Writing a Topology](./guides/writing-a-topology.md) - [Adding Capabilities](./guides/adding-capabilities.md) +- [Web Authentication and CSRF Security](./guides/web-auth-security.md) ## Configuration diff --git a/docs/guides/web-auth-security.md b/docs/guides/web-auth-security.md new file mode 100644 index 00000000..8c52a52e --- /dev/null +++ b/docs/guides/web-auth-security.md @@ -0,0 +1,217 @@ +# Web Authentication and CSRF Security Guidelines + +These guidelines define the baseline for Harmony web frontends and future operator dashboards that use browser-based authentication, cookie sessions, Axum, HTMX, or OIDC providers such as Zitadel. + +## Goals + +- Prevent unauthenticated access. +- Prevent authenticated users from performing actions they are not authorized to perform. +- Prevent CSRF on state-changing endpoints. +- Reduce XSS impact with CSP and safe rendering practices. +- Keep authentication code understandable and reusable across projects. + +## Required Baseline + +Every browser-facing authenticated application must implement the following controls before production use: + +1. **OIDC Authorization Code + PKCE** for login. +2. **OIDC nonce validation** on login callback. +3. **Explicit authorization checks** using roles, groups, claims, or permissions. +4. **CSRF protection** on all mutating routes. +5. **Secure cookie settings**: `HttpOnly`, `Secure` in production, constrained `SameSite`, and appropriate path/domain scoping. +6. **Strict security headers**, especially Content Security Policy. +7. **No permissive credentialed CORS** for operator dashboards. +8. **Generic client-facing errors** with detailed errors logged server-side only. + +## OIDC Login Requirements + +Use Authorization Code flow with PKCE. On login start, generate and persist a short-lived login attempt containing: + +- `state` +- `pkce_code_verifier` +- `nonce` +- creation timestamp or cookie expiration + +Send `state`, PKCE challenge, and `nonce` to the authorization endpoint. + +On callback: + +1. Require a valid login-attempt cookie. +2. Validate returned `state` against the stored state. +3. Exchange the authorization code using the stored PKCE verifier. +4. Validate the returned ID token as an OIDC ID token, including: + - signature + - issuer + - audience/client ID + - expiration/not-before + - nonce + - authorized party (`azp`) when applicable +5. Create the application session only after all checks pass. +6. Delete the login-attempt cookie. + +`state` and `nonce` are not interchangeable: + +- `state` binds the callback redirect to the browser login attempt. +- `nonce` binds the returned ID token to the browser login attempt. +- PKCE binds the code exchange to the client that started the flow. + +## Session Requirements + +For small internal dashboards, a verified short-lived ID token in an `HttpOnly` cookie may be acceptable. For higher-risk systems, prefer server-side sessions: + +- Store a random session ID in the browser cookie. +- Store tokens and session metadata server-side. +- Support revocation, rotation, idle timeout, and absolute timeout. + +Session cookies must use: + +- `HttpOnly` +- `Secure` outside local development +- `SameSite=Lax` or `SameSite=Strict` +- `Path=/` unless a narrower path is possible +- No broad `Domain` attribute unless explicitly required + +Production services should fail closed if HTTPS/secure-cookie configuration is inconsistent. + +## Authorization Requirements + +Authentication is not authorization. A valid identity provider token only proves who the user is. + +Every protected application must define required permissions for each state-changing or sensitive route. Examples: + +- `fleet:viewer` for read-only dashboard access +- `fleet:operator` for alert acknowledgement and operational actions +- `fleet:admin` for settings, user management, or destructive actions + +Authorization must be enforced server-side. UI hiding is not sufficient. + +## CSRF Protection Standard + +For Axum + HTMX dashboards, the recommended baseline is: + +1. Require a custom header on all mutating requests. +2. Validate `Origin` or `Referer` against the configured application origin. +3. Keep cookies `SameSite=Lax` or stricter. +4. Do not enable permissive credentialed CORS. + +Mutating methods are: + +- `POST` +- `PUT` +- `PATCH` +- `DELETE` + +Recommended behavior: + +- Reject mutating requests without `x-csrf-token`. +- Reject mutating requests whose `Origin` is present and does not match the configured base URL origin. +- If `Origin` is absent, require `Referer` to match the configured base URL origin. +- Reject when neither `Origin` nor `Referer` is available, unless the route is explicitly exempted and documented. + +The CSRF header value may be static for HTMX dashboards, for example `x-csrf-token: 1`. The protection comes from the fact that cross-origin HTML forms cannot set custom headers, and cross-origin JavaScript cannot send custom headers with credentials unless CORS allows it. + +Do not rely on header presence alone if adding Origin/Referer validation is practical. + +## HTMX Integration + +Add the CSRF header globally from a static JavaScript file: + +```js +document.body.addEventListener('htmx:configRequest', (event) => { + event.detail.headers['x-csrf-token'] = '1'; +}); +``` + +Serve this as a static asset, for example `/static/app.js`. Avoid inline scripts so that the application can use a strict CSP without `unsafe-inline`. + +## Content Security Policy + +Every browser-facing dashboard should set a restrictive CSP. A good starting point is: + +```http +Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none' +``` + +Meaning: + +- Only load scripts, styles, and API/SSE/HTMX connections from the same origin. +- Prevent clickjacking with `frame-ancestors 'none'`. +- Prevent plugin/object execution with `object-src 'none'`. +- Prevent injected `` tags from rewriting relative URLs. +- Prevent forms from submitting to external origins. + +If inline scripts or styles are unavoidable, prefer per-response nonces over `unsafe-inline`. + +## Other Security Headers + +Set these headers on all HTML responses, or globally when safe: + +```http +X-Content-Type-Options: nosniff +Referrer-Policy: same-origin +Permissions-Policy: geolocation=(), microphone=(), camera=() +``` + +When the service is HTTPS-only, also set HSTS: + +```http +Strict-Transport-Security: max-age=31536000; includeSubDomains +``` + +Only enable HSTS when the domain and subdomains are intended to be HTTPS-only. + +## CORS Policy + +Operator dashboards should normally not enable CORS. + +Never combine all of the following unless there is a reviewed, explicit integration need: + +- credentialed requests +- arbitrary or reflected origins +- custom request headers such as `x-csrf-token` + +A permissive credentialed CORS policy can bypass custom-header CSRF protection. + +## Error Handling + +Client-facing auth errors should be generic, for example: + +```text +Authentication failed. Please start login again. +``` + +Detailed causes, provider responses, token validation failures, and stack traces should be logged server-side only. + +Avoid returning raw OIDC provider error bodies or JWT validation details to the browser. + +## Implementation Checklist + +Before shipping a Harmony web frontend: + +- [ ] Login uses Authorization Code + PKCE. +- [ ] Login attempt stores `state`, PKCE verifier, `nonce`, and expires quickly. +- [ ] Callback validates `state`. +- [ ] Callback validates ID token nonce. +- [ ] JWT validation checks issuer and exact intended audience/client. +- [ ] Authorization roles/permissions are enforced server-side. +- [ ] Mutating routes are protected by CSRF middleware. +- [ ] CSRF middleware requires custom header and same-origin `Origin`/`Referer`. +- [ ] Session cookies are `HttpOnly`, `Secure` in production, and `SameSite=Lax` or stricter. +- [ ] No permissive credentialed CORS is enabled. +- [ ] CSP is configured without `unsafe-inline` where practical. +- [ ] Security headers are configured. +- [ ] Auth errors shown to users are generic. +- [ ] Detailed auth failures are logged server-side. + +## Recommended Default for Harmony Dashboards + +For current and future Axum + HTMX dashboards, use this default design: + +- Zitadel/OIDC Authorization Code + PKCE + nonce. +- Short-lived encrypted login-attempt cookie. +- Server-side authorization middleware based on roles/claims. +- `HttpOnly`, `Secure`, `SameSite=Lax` or `Strict` session cookie. +- CSRF middleware requiring `x-csrf-token` and same-origin `Origin`/`Referer`. +- Static `/static/app.js` that adds the HTMX CSRF header. +- Strict CSP that allows scripts only from `self`. +- No CORS unless explicitly reviewed. diff --git a/fleet/harmony-fleet-operator/src/frontend/assets.rs b/fleet/harmony-fleet-operator/src/frontend/assets.rs index 627bd85f..1b0be691 100644 --- a/fleet/harmony-fleet-operator/src/frontend/assets.rs +++ b/fleet/harmony-fleet-operator/src/frontend/assets.rs @@ -8,3 +8,4 @@ pub const TAILWIND_CSS: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/tailwind.css")); pub const HTMX_JS: &[u8] = include_bytes!("../../vendor/htmx.min.js"); pub const HTMX_SSE_JS: &[u8] = include_bytes!("../../vendor/htmx-ext-sse.js"); +pub const APP_JS: &[u8] = include_bytes!("../../vendor/app.js"); diff --git a/fleet/harmony-fleet-operator/src/frontend/layout.rs b/fleet/harmony-fleet-operator/src/frontend/layout.rs index fcfc40d7..7e039d58 100644 --- a/fleet/harmony-fleet-operator/src/frontend/layout.rs +++ b/fleet/harmony-fleet-operator/src/frontend/layout.rs @@ -1,23 +1,24 @@ -//! Page shell — full-document wrapper with left sidebar navigation. - use maud::{DOCTYPE, Markup, PreEscaped, html}; use crate::frontend::auth::DashboardSession; -// Inline SVG icons (no external dependency). -const ICON_DEVICES: &str = r#""#; -const ICON_DEPLOY: &str = r#""#; -const ICON_DASHBOARD: &str = r#""#; -const ICON_LOGOUT: &str = r#""#; +// ── Inline SVG icons ──────────────────────────────────────────────────── -/// Render a full page with the left sidebar layout. -/// -/// `current_path` is used to highlight the active nav item (e.g. "/", "/devices"). +const ICON_DASHBOARD: &str = r#""#; +const ICON_DEVICES: &str = r#""#; +const ICON_DEPLOY: &str = r#""#; +const ICON_BELL: &str = r#""#; +const ICON_COG: &str = r#""#; +const ICON_LOGOUT: &str = r#""#; +const ICON_BRAND: &str = r#""#; + +/// Render a full page with sidebar + topbar layout. pub fn page( title: &str, live_reload: bool, current_path: &str, session: Option<&DashboardSession>, + unacked_alerts: usize, content: Markup, ) -> Markup { html! { @@ -30,69 +31,94 @@ pub fn page( link rel="stylesheet" href="/static/tailwind.css"; script src="/static/htmx.min.js" defer {} script src="/static/htmx-ext-sse.js" defer {} + script src="/static/app.js" defer {} @if live_reload { script { (PreEscaped(LIVE_RELOAD_JS)) } } } - body class="min-h-screen bg-slate-950 text-slate-100" hx-ext="sse" { - div class="flex h-screen overflow-hidden" { - (sidebar(current_path, session)) - main class="flex-1 overflow-y-auto p-6" { (content) } + body class="min-h-screen" hx-ext="sse" style="background:var(--bg); color:#e2e8f0; font-family:'Inter',sans-serif" { + div class="flex h-screen overflow-hidden" style="background:var(--bg)" { + (sidebar(current_path, session, unacked_alerts)) + main class="flex-1 min-w-0 flex flex-col overflow-hidden" { + (topbar(title, unacked_alerts)) + div class="flex-1 overflow-y-auto grid-bg" { (content) } + } + } + div id="modal-root" {} + } + } + } +} + +fn sidebar( + current_path: &str, + session: Option<&DashboardSession>, + unacked_alerts: usize, +) -> Markup { + let nav_items: [(&str, &str, &str, usize); 5] = [ + ("/", ICON_DASHBOARD, "Dashboard", 0), + ("/devices", ICON_DEVICES, "Devices", 0), + ("/deployments", ICON_DEPLOY, "Deployments", 0), + ("/alerts", ICON_BELL, "Alerts", unacked_alerts), + ("/settings", ICON_COG, "Settings", 0), + ]; + + html! { + aside class="shrink-0 flex flex-col border-r w-[224px]" style="border-color:var(--border); background:var(--bg)" { + div class="flex items-center justify-between px-4 py-4 border-b" style="border-color:var(--border)" { + div class="flex items-center gap-2" { + div class="relative w-6 h-6 rounded-md flex items-center justify-center" style="background:var(--accent); color:#0c0c0c" { + (PreEscaped(ICON_BRAND)) + } + span class="text-sm font-semibold tracking-tight text-slate-100" { "Harmony Fleet" } } } - } - } -} -fn sidebar(current_path: &str, session: Option<&DashboardSession>) -> Markup { - html! { - aside class="w-56 shrink-0 border-r border-slate-800 bg-slate-950 flex flex-col" { - div class="px-4 py-4 border-b border-slate-800" { - h1 class="text-sm font-semibold tracking-tight" { "Harmony Fleet" } - } - nav class="flex-1 overflow-y-auto px-3 py-3 space-y-0.5" { - (nav_link("/", ICON_DASHBOARD, "Dashboard", current_path == "/")) - (nav_link("/devices", ICON_DEVICES, "Devices", current_path == "/devices")) - (nav_link("/deployments", ICON_DEPLOY, "Deployments", current_path == "/deployments")) + nav class="flex-1 px-2 py-3 space-y-0.5" { + @for (href, icon, label, badge) in &nav_items { + @let active = is_active(current_path, href); + a + href=(*href) + class={"group w-full flex items-center gap-2.5 px-2.5 h-9 rounded-md text-[13px] transition-colors duration-150 relative " + (if active { "text-slate-100 font-medium" } else { "text-slate-400 hover:text-slate-100" })} + style={(if active { "background:rgba(148,163,184,0.06)" } else { "background:transparent" })} + { + @if active { + span class="absolute left-0 top-1.5 bottom-1.5 w-[2px] rounded-r" style="background:var(--accent)" {} + } + span class={(if active { "text-slate-100" } else { "text-slate-500 group-hover:text-slate-300" })} { + (PreEscaped(icon)) + } + span class="flex-1 text-left" { (label) } + @if *badge > 0 { + span class="inline-flex items-center justify-center min-w-[18px] h-[18px] rounded-full text-[10px] font-semibold px-1" style="background:var(--bad); color:#0c0c0c" { + (badge) + } + } + } + } } + @if let Some(s) = session { - (user_footer(s)) + div class="border-t p-3" style="border-color:var(--border)" { + (user_footer(s)) + } } } } } -fn nav_link(href: &str, icon: &str, label: &str, active: bool) -> Markup { - let base = - "flex items-center gap-2.5 px-3 py-2 rounded-md text-sm transition-colors duration-150"; - let cls = if active { - "bg-slate-800 text-slate-100 font-medium" - } else { - "text-slate-400 hover:bg-slate-900 hover:text-slate-200" - }; - html! { - a href=(href) class={(base) " " (cls)} { - (PreEscaped(icon)) - (label) - } - } -} - fn user_footer(session: &DashboardSession) -> Markup { - let label = session + let initials = session .name .as_deref() .and_then(|name| { - let initials: String = name + let s: String = name .split_whitespace() .filter_map(|w| w.chars().next()) .collect::() .to_uppercase(); - if initials.is_empty() { - None - } else { - Some(initials) - } + if s.is_empty() { None } else { Some(s) } }) .unwrap_or_else(|| { session @@ -110,31 +136,74 @@ fn user_footer(session: &DashboardSession) -> Markup { .unwrap_or(&session.subject); html! { - div class="border-t border-slate-800 p-3" { - div class="flex items-center gap-3" { - div class="flex items-center justify-center w-8 h-8 rounded-full bg-slate-800 text-xs font-medium text-slate-300 shrink-0" { - (label) + div class="flex items-center gap-3" { + div class="flex items-center justify-center w-8 h-8 rounded-full text-[11px] font-medium text-slate-300 shrink-0" style="background:rgba(148,163,184,0.1)" { + (initials) + } + div class="min-w-0" { + p class="text-[13px] text-slate-200 truncate leading-tight" { (display) } + @if let Some(email) = session.email.as_deref() { + p class="text-[11px] text-slate-500 truncate leading-tight" { (email) } } + } + } + a + href="/logout" + class="mt-2 flex items-center gap-1.5 text-[11px] text-slate-500 hover:text-rose-400 transition-colors" + { + (PreEscaped(ICON_LOGOUT)) + span { "Log out" } + } + } +} + +fn topbar(title: &str, unacked_alerts: usize) -> Markup { + html! { + div class="flex items-center justify-between px-6 h-14 border-b shrink-0" style="border-color:var(--border); background:var(--bg)" { + div class="flex items-center gap-3 min-w-0" { div class="min-w-0" { - p class="text-sm text-slate-200 truncate" { (display) } - @if let Some(email) = session.email.as_deref() { - p class="text-xs text-slate-500 truncate" { (email) } + h1 class="text-[15px] font-semibold text-slate-100 flex items-center gap-2 truncate" { + (title) } } } - a - href="/logout" - class="mt-2 flex items-center gap-2 text-xs text-slate-500 hover:text-rose-400 transition-colors duration-150" - { - (PreEscaped(ICON_LOGOUT)) - "Log out" + div class="flex items-center gap-2" { + div class="relative" { + input + class="input w-64" + type="text" + name="search" + placeholder="Search devices, deployments\u{2026}" + hx-get="/devices/search" + hx-trigger="keyup changed delay:300ms" + hx-target="#device-table-wrapper" + hx-swap="innerHTML"; + } + a href="/alerts" class="relative btn btn-ghost py-1.5" { + (PreEscaped(ICON_BELL)) + span { "Alerts" } + @if unacked_alerts > 0 { + span class="ml-1 inline-flex items-center justify-center min-w-[18px] h-[18px] rounded-full text-[10px] font-semibold px-1" style="background:var(--bad); color:#0c0c0c" { + (unacked_alerts) + } + } + } + a href="/settings" class="btn btn-ghost py-1.5" title="Settings" { + (PreEscaped(ICON_COG)) + } } } } } -/// Tiny inline script: reconnects an EventSource to `/__dev/reload`; -/// when the server comes back up after a restart, reload the page. +fn is_active(current: &str, href: &str) -> bool { + if href == "/" { + current == "/" + } else { + current.starts_with(href) + } +} + const LIVE_RELOAD_JS: &str = r#" (function(){ let connected = false; diff --git a/fleet/harmony-fleet-operator/src/frontend/server.rs b/fleet/harmony-fleet-operator/src/frontend/server.rs index a62d13b2..035362b2 100644 --- a/fleet/harmony-fleet-operator/src/frontend/server.rs +++ b/fleet/harmony-fleet-operator/src/frontend/server.rs @@ -7,39 +7,40 @@ use std::time::Duration; use anyhow::Result; use axum::Router; use axum::body::Body; -use axum::extract::{Extension, FromRef, Path, State}; +use axum::extract::{Extension, FromRef, Path, Query, State}; use axum::http::Request; -use axum::http::{StatusCode, header}; +use axum::http::{HeaderValue, Method, StatusCode, header}; use axum::middleware::{self, Next}; use axum::response::sse::{Event, KeepAlive, Sse}; use axum::response::{IntoResponse, Redirect, Response}; use axum::routing::{get, post}; use axum_extra::extract::cookie::{Cookie, Key, PrivateCookieJar}; use maud::Markup; +use serde::Deserialize; use tokio_stream::StreamExt; use tokio_stream::wrappers::IntervalStream; -use super::assets::{HTMX_JS, HTMX_SSE_JS, TAILWIND_CSS}; +use super::assets::{APP_JS, HTMX_JS, HTMX_SSE_JS, TAILWIND_CSS}; use super::layout::page; -use super::views::{dashboard, deployments as deployments_view, devices as devices_view}; -use crate::frontend::auth::{self, DASHBOARD_SESSION_COOKIE, DashboardSession}; +use super::views::{ + alerts as alerts_view, dashboard as dashboard_view, deployments as deployments_view, + devices as devices_view, settings as settings_view, +}; +use crate::frontend::auth::{self, DASHBOARD_SESSION_COOKIE, DashboardSession, JwksCache}; use crate::service::FleetService; +use harmony_zitadel_auth::ZitadelAuthConfig; -/// Default high port — keeps clear of NATS (4222), k8s API (6443), -/// and common metrics/webhook ports (8080/9090/9443). pub const DEFAULT_PORT: u16 = 18080; #[derive(Clone)] pub struct AppState { pub fleet: Arc, pub cookie_key: Key, - /// Read Tailwind CSS from this path on every request when set. - /// Lets a sidecar `tailwindcss --watch` drive iteration without - /// recompiling the binary. pub css_override: Option, - /// When true, inject the live-reload script into pages and expose - /// `/__dev/reload`. pub live_reload: bool, + pub config: ZitadelAuthConfig, + pub http_client: reqwest::Client, + pub jwks: JwksCache, } impl FromRef for Key { @@ -48,6 +49,24 @@ impl FromRef for Key { } } +impl FromRef for ZitadelAuthConfig { + fn from_ref(state: &AppState) -> Self { + state.config.clone() + } +} + +impl FromRef for reqwest::Client { + fn from_ref(state: &AppState) -> Self { + state.http_client.clone() + } +} + +impl FromRef for JwksCache { + fn from_ref(state: &AppState) -> Self { + state.jwks.clone() + } +} + pub struct Config { pub addr: SocketAddr, pub state: AppState, @@ -73,17 +92,32 @@ pub fn router(state: AppState) -> Router { .route("/auth/callback", get(auth::callback_handler)) .route("/static/tailwind.css", get(tailwind_css)) .route("/static/htmx.min.js", get(htmx_js)) - .route("/static/htmx-ext-sse.js", get(htmx_sse_js)); + .route("/static/htmx-ext-sse.js", get(htmx_sse_js)) + .route("/static/app.js", get(app_js)); let private_routes = Router::new() + // Dashboard .route("/", get(dashboard_handler)) - .route("/logout", get(auth::logout_handler)) + // Devices .route("/devices", get(devices_handler)) + .route("/devices/search", get(devices_search_handler)) .route("/devices/{id}/blacklist", post(blacklist_handler)) - .route("/deployments", get(deployments_handler)) - .route("/deployment/{id}", get(deployment_handler)) .route("/devices/{id}/logs", get(device_logs_handler)) .route("/devices/{id}/logs/stream", get(device_logs_stream_handler)) + // Device detail + .route("/device/{id}", get(device_detail_handler)) + // Deployments + .route("/deployments", get(deployments_handler)) + .route("/deployment/{id}", get(deployment_handler)) + // Alerts + .route("/alerts", get(alerts_handler)) + .route("/alerts/{id}/ack", post(ack_alert_handler)) + // Settings + .route("/settings", get(settings_handler)) + .route("/settings/toggle/{key}", post(settings_toggle_handler)) + // Logout + .route("/logout", get(auth::logout_handler)) + .route_layer(middleware::from_fn_with_state(state.clone(), csrf_protect)) .route_layer(middleware::from_fn_with_state(state.clone(), require_auth)); let mut r = public_routes.merge(private_routes); @@ -92,33 +126,130 @@ pub fn router(state: AppState) -> Router { r = r.route("/__dev/reload", get(dev_reload_sse)); } - r.with_state(state) + r.layer(middleware::from_fn_with_state( + state.clone(), + security_headers, + )) + .with_state(state) } -async fn require_auth(jar: PrivateCookieJar, mut req: Request, next: Next) -> Response { +async fn require_auth( + State(state): State, + jar: PrivateCookieJar, + mut req: Request, + next: Next, +) -> Response { let Some(cookie) = jar.get(DASHBOARD_SESSION_COOKIE) else { return unauthenticated_response(&req); }; - let session = serde_json::from_str::(cookie.value()).and_then(|session| { - auth::validate_harmony_auth_session(&session) - .map(|_| session) - .map_err(serde::de::Error::custom) - }); - - match session { + match state.jwks.verify(cookie.value(), &state.config).await { Ok(session) => { req.extensions_mut().insert(session); next.run(req).await } Err(e) => { - tracing::warn!(%e, "invalid dashboard session"); + tracing::warn!(%e, "invalid session cookie"); let jar = jar.remove(Cookie::from(DASHBOARD_SESSION_COOKIE)); (jar, unauthenticated_response(&req)).into_response() } } } +async fn csrf_protect(State(state): State, req: Request, next: Next) -> Response { + if !is_mutating_method(req.method()) { + return next.run(req).await; + } + + if req.headers().get("x-csrf-token").is_none() { + return (StatusCode::FORBIDDEN, "CSRF check failed").into_response(); + } + + if !is_same_origin_request(&req, &state.config.base_url) { + return (StatusCode::FORBIDDEN, "CSRF origin check failed").into_response(); + } + + next.run(req).await +} + +fn is_mutating_method(method: &Method) -> bool { + matches!( + *method, + Method::POST | Method::PUT | Method::PATCH | Method::DELETE + ) +} + +fn is_same_origin_request(req: &Request, base_url: &str) -> bool { + let Ok(expected) = url::Url::parse(base_url) else { + tracing::error!(%base_url, "invalid BASE_URL; rejecting mutating request"); + return false; + }; + + if let Some(origin) = req + .headers() + .get(header::ORIGIN) + .and_then(|v| v.to_str().ok()) + { + return origin_matches(origin, &expected); + } + + req.headers() + .get(header::REFERER) + .and_then(|v| v.to_str().ok()) + .is_some_and(|referer| origin_matches(referer, &expected)) +} + +fn origin_matches(candidate: &str, expected: &url::Url) -> bool { + let Ok(candidate) = url::Url::parse(candidate) else { + return false; + }; + + candidate.scheme() == expected.scheme() + && candidate.host_str() == expected.host_str() + && candidate.port_or_known_default() == expected.port_or_known_default() +} + +async fn security_headers( + State(state): State, + req: Request, + next: Next, +) -> Response { + let mut response = next.run(req).await; + let headers = response.headers_mut(); + + let csp = if state.live_reload { + "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'" + } else { + "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'" + }; + + headers.insert( + header::CONTENT_SECURITY_POLICY, + HeaderValue::from_static(csp), + ); + headers.insert( + header::X_CONTENT_TYPE_OPTIONS, + HeaderValue::from_static("nosniff"), + ); + headers.insert( + header::REFERRER_POLICY, + HeaderValue::from_static("same-origin"), + ); + headers.insert( + "Permissions-Policy", + HeaderValue::from_static("geolocation=(), microphone=(), camera=()"), + ); + + if state.config.use_secure_cookies() { + headers.insert( + header::STRICT_TRANSPORT_SECURITY, + HeaderValue::from_static("max-age=31536000; includeSubDomains"), + ); + } + + response +} + fn unauthenticated_response(req: &Request) -> Response { if is_sse_request(req) { return (StatusCode::UNAUTHORIZED, "authentication required").into_response(); @@ -157,6 +288,314 @@ pub async fn run(cfg: Config) -> Result<()> { Ok(()) } +// ── Dashboard ────────────────────────────────────────────────────────── + +async fn dashboard_handler( + State(s): State, + session: Option>, +) -> Result { + let detail = s.fleet.dashboard_detail().await?; + let unacked = detail.active_alerts.iter().filter(|a| !a.acked).count(); + Ok(page( + "Dashboard", + s.live_reload, + "/", + session.as_ref().map(|e| &e.0), + unacked, + dashboard_view::page(&detail), + )) +} + +// ── Devices ──────────────────────────────────────────────────────────── + +#[derive(Deserialize, Default)] +struct DevicesQuery { + status: Option, + deployment: Option, + region: Option, + search: Option, +} + +async fn devices_handler( + State(s): State, + Query(q): Query, + session: Option>, +) -> Result { + let status = q.status.as_deref().and_then(|s| parse_device_status(s)); + + let devices = s + .fleet + .filtered_devices( + status, + q.deployment.clone(), + q.region.clone(), + q.search.clone(), + ) + .await?; + + let all_devices = s.fleet.list_devices().await?; + let all_regions: Vec = { + let mut r: Vec = all_devices.iter().map(|d| d.region.clone()).collect(); + r.sort(); + r.dedup(); + r + }; + let all_deployments: Vec = { + let deps = s.fleet.list_deployments().await?; + deps.into_iter().map(|d| d.name).collect() + }; + + let unacked = s + .fleet + .list_alerts() + .await? + .iter() + .filter(|a| !a.acked) + .count(); + + Ok(page( + "Devices", + s.live_reload, + "/devices", + session.as_ref().map(|e| &e.0), + unacked, + devices_view::page( + &devices, + &all_regions, + &all_deployments, + status, + q.deployment.as_deref(), + q.region.as_deref(), + q.search.as_deref(), + ), + )) +} + +async fn devices_search_handler( + State(s): State, + Query(q): Query, +) -> Result { + let status = q.status.as_deref().and_then(|s| parse_device_status(s)); + + let devices = s + .fleet + .filtered_devices( + status, + q.deployment.clone(), + q.region.clone(), + q.search.clone(), + ) + .await?; + + Ok(devices_view::page( + &devices, + &[], + &[], + status, + q.deployment.as_deref(), + q.region.as_deref(), + q.search.as_deref(), + )) +} + +// ── Device detail ────────────────────────────────────────────────────── + +#[derive(Deserialize, Default)] +struct DeviceDetailQuery { + tab: Option, +} + +async fn device_detail_handler( + State(s): State, + Path(id): Path, + Query(q): Query, + session: Option>, +) -> Result { + let device = s + .fleet + .get_device(&id) + .await? + .ok_or_else(|| anyhow::anyhow!("device not found: {id}"))?; + + let deployment_version = if let Some(ref dep_name) = device.deployment { + s.fleet.get_deployment(dep_name).await?.map(|d| d.version) + } else { + None + }; + + let tab = q.tab.as_deref().unwrap_or("overview"); + + // If a specific tab is requested via query param, return tab content only (for HTMX) + if q.tab.is_some() { + return Ok(devices_view::tab_content( + &device, + tab, + deployment_version.as_deref(), + )); + } + + let unacked = s + .fleet + .list_alerts() + .await? + .iter() + .filter(|a| !a.acked) + .count(); + + Ok(page( + &device.id, + s.live_reload, + "/devices", + session.as_ref().map(|e| &e.0), + unacked, + devices_view::detail(&device, deployment_version.as_deref()), + )) +} + +// ── Deployments ──────────────────────────────────────────────────────── + +async fn deployments_handler( + State(s): State, + session: Option>, +) -> Result { + let deployments = s.fleet.list_deployments().await?; + let unacked = s + .fleet + .list_alerts() + .await? + .iter() + .filter(|a| !a.acked) + .count(); + + Ok(page( + "Deployments", + s.live_reload, + "/deployments", + session.as_ref().map(|e| &e.0), + unacked, + deployments_view::page(&deployments), + )) +} + +// ── Deployment detail ────────────────────────────────────────────────── + +#[derive(Deserialize, Default)] +struct DeploymentQuery { + tab: Option, + task_view: Option, +} + +async fn deployment_handler( + State(s): State, + Path(id): Path, + Query(q): Query, + session: Option>, +) -> Result { + let deployment = s + .fleet + .get_deployment(&id) + .await? + .ok_or_else(|| anyhow::anyhow!("deployment not found: {id}"))?; + + let devices = s.fleet.get_deployment_devices(&id).await?; + let task_graph = s.fleet.get_task_graph(&id).await?; + let task_view = q.task_view.as_deref().unwrap_or("linear"); + let tab = q.tab.as_deref().unwrap_or("overview"); + let unacked = s + .fleet + .list_alerts() + .await? + .iter() + .filter(|a| !a.acked) + .count(); + + if q.tab.is_some() && q.tab.as_deref() != Some("overview") { + // HTMX tab content only + Ok(deployments_view::tab_content( + &deployment, + &devices, + &task_graph, + task_view, + tab, + )) + } else { + Ok(page( + &deployment.name, + s.live_reload, + "/deployments", + session.as_ref().map(|e| &e.0), + unacked, + deployments_view::detail(&deployment, &devices, &task_graph, task_view), + )) + } +} + +// ── Alerts ───────────────────────────────────────────────────────────── + +async fn alerts_handler( + State(s): State, + session: Option>, +) -> Result { + let alerts = s.fleet.list_alerts().await?; + let unacked = alerts.iter().filter(|a| !a.acked).count(); + + Ok(page( + "Alerts", + s.live_reload, + "/alerts", + session.as_ref().map(|e| &e.0), + unacked, + alerts_view::page(&alerts), + )) +} + +async fn ack_alert_handler( + State(s): State, + Path(id): Path, +) -> Result { + s.fleet.ack_alert(&id).await?; + let alerts = s.fleet.list_alerts().await?; + let alert = alerts + .iter() + .find(|a| a.id == id) + .ok_or_else(|| anyhow::anyhow!("alert not found: {id}"))?; + + // Return just the row (for HTMX swap) + Ok(alerts_view::alert_row(alert)) +} + +// ── Settings ─────────────────────────────────────────────────────────── + +async fn settings_handler( + State(s): State, + session: Option>, +) -> Result { + let unacked = s + .fleet + .list_alerts() + .await? + .iter() + .filter(|a| !a.acked) + .count(); + + Ok(page( + "Settings", + s.live_reload, + "/settings", + session.as_ref().map(|e| &e.0), + unacked, + settings_view::page(), + )) +} + +async fn settings_toggle_handler(Path(_key): Path) -> Result { + // In a real app this would toggle a notification channel. + // For the mock, we return the same static content. + Ok(settings_view::page()) +} + +// ── Device logs ──────────────────────────────────────────────────────── + async fn device_logs_handler(Path(id): Path) -> Result { Ok(devices_view::logs_modal(&id)) } @@ -165,88 +604,39 @@ async fn device_logs_stream_handler( Path(id): Path, ) -> Sse>> { let mut line_no = 0usize; - let stream = IntervalStream::new(tokio::time::interval(Duration::from_secs(1))).map(move |_| { - line_no += 1; - let now = chrono::Utc::now().format("%H:%M:%S"); - let html = format!( - r#"
{now}{id} mock log line #{line_no}
"#, - ); + let stream = IntervalStream::new(tokio::time::interval(Duration::from_secs(1))).map( + move |_| { + line_no += 1; + let now = chrono::Utc::now().format("%H:%M:%S"); + let sevs = ["debug", "info", "info", "info", "info", "warn", "error"]; + let _sev = sevs[line_no % sevs.len()]; + let msgs = [ + "agent heartbeat ok (latency 12ms)", + "mqtt connection established to broker.harmony.local", + "reporting metrics batch (48 samples)", + "config reload requested by control-plane", + "task t4 (install deps) progress 73%", + "unexpected schema version v3, falling back", + "sensord pid=2841 started", + "gpu temp 54\u{b0}C \u{2014} within range", + "apt-get: package libsensor-7 not found", + "flushed 19 pending events to ingest", + "network jitter 84ms \u{2014} degrading to backup link", + "gc cycle complete in 47ms", + ]; + let msg = msgs[line_no % msgs.len()]; + let html = format!( + r#"
{now}{id} {msg}
"#, + ); - Ok::<_, Infallible>(Event::default().event("log").data(html)) - }); + Ok::<_, Infallible>(Event::default().event("log").data(html)) + }, + ); Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15))) } -// ---- handlers: each is a 3-liner: extract state, call service, render. ---- - -async fn dashboard_handler( - State(s): State, - session: Option>, -) -> Result { - let summary = s.fleet.dashboard_summary().await?; - Ok(page( - "Dashboard", - s.live_reload, - "/", - session.as_ref().map(|e| &e.0), - dashboard::page(&summary), - )) -} - -async fn devices_handler( - State(s): State, - session: Option>, -) -> Result { - let devices = s.fleet.list_devices().await?; - Ok(page( - "Devices", - s.live_reload, - "/devices", - session.as_ref().map(|e| &e.0), - devices_view::page(&devices), - )) -} - -async fn deployments_handler( - State(s): State, - session: Option>, -) -> Result { - let deployments = s.fleet.list_deployments().await?; - Ok(page( - "Deployments", - s.live_reload, - "/deployments", - session.as_ref().map(|e| &e.0), - deployments_view::page(&deployments), - )) -} - -async fn deployment_handler( - State(s): State, - Path(id): Path, - session: Option>, -) -> Result { - let deployments = s.fleet.list_deployments().await?; - let deployment = deployments - .iter() - .find(|d| d.name == id) - .ok_or_else(|| anyhow::anyhow!("deployment not found: {id}"))?; - - let devices = s.fleet.list_devices().await?; - let deployment_devices: Vec<_> = devices - .into_iter() - .filter(|device| device.deployment.as_deref() == Some(id.as_str())) - .collect(); - - Ok(page( - "Deployment", - s.live_reload, - "/deployments", - session.as_ref().map(|e| &e.0), - deployments_view::detail(deployment, &deployment_devices), - )) -} +// ── Blacklist ────────────────────────────────────────────────────────── async fn blacklist_handler( State(s): State, @@ -256,7 +646,21 @@ async fn blacklist_handler( Ok(devices_view::row(&updated)) } -// ---- static assets ---- +// ── Helpers ──────────────────────────────────────────────────────────── + +fn parse_device_status(s: &str) -> Option { + match s { + "healthy" => Some(crate::service::DeviceStatus::Healthy), + "pending" => Some(crate::service::DeviceStatus::Pending), + "failing" => Some(crate::service::DeviceStatus::Failing), + "stale" => Some(crate::service::DeviceStatus::Stale), + "blacklisted" => Some(crate::service::DeviceStatus::Blacklisted), + "unknown" => Some(crate::service::DeviceStatus::Unknown), + _ => None, + } +} + +// ── Static assets ────────────────────────────────────────────────────── async fn tailwind_css(State(s): State) -> Response { let css: Vec = match &s.css_override { @@ -283,6 +687,10 @@ async fn htmx_sse_js() -> Response { ) } +async fn app_js() -> Response { + static_response(APP_JS.to_vec(), "application/javascript; charset=utf-8") +} + fn static_response(bytes: Vec, content_type: &'static str) -> Response { Response::builder() .status(StatusCode::OK) @@ -291,19 +699,15 @@ fn static_response(bytes: Vec, content_type: &'static str) -> Response { .expect("well-formed static response") } -// ---- dev live-reload SSE ---- +// ── Dev live-reload SSE ──────────────────────────────────────────────── async fn dev_reload_sse() -> Sse>> { - // We never send actual reload events from here. The browser-side - // pattern is simpler: on EventSource reconnect after the server - // came back up, reload the page. So all we do is hold the - // connection open with keep-alive pings. let stream = tokio_stream::iter([Ok::<_, Infallible>(Event::default().data("ready"))]) .chain(tokio_stream::pending()); Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15))) } -// ---- error type ---- +// ── Error type ───────────────────────────────────────────────────────── pub struct AppError(anyhow::Error); diff --git a/fleet/harmony-fleet-operator/src/frontend/views/alerts.rs b/fleet/harmony-fleet-operator/src/frontend/views/alerts.rs new file mode 100644 index 00000000..d3db66be --- /dev/null +++ b/fleet/harmony-fleet-operator/src/frontend/views/alerts.rs @@ -0,0 +1,125 @@ +use maud::{Markup, html}; + +use crate::frontend::views::badges; +use crate::service::Alert; + +pub fn page(alerts: &[Alert]) -> Markup { + let unacked = alerts.iter().filter(|a| !a.acked).count(); + html! { + div class="p-6 space-y-4" { + div class="flex items-center gap-2" { + h2 class="text-[15px] font-semibold text-slate-200" { "Alerts" } + span class="text-[11px] text-slate-500" { "\u{b7} " (unacked) " unacked" } + div class="flex-1" {} + button class="btn btn-ghost" { "Ack all" } + } + div class="card card-flush" { + table class="tbl" { + thead { + tr { + th style="width:32px" {} + th { "Severity" } + th { "Alert" } + th { "Source" } + th { "Time" } + th class="text-right" { "Action" } + } + } + tbody { + @for a in alerts { + tr class={(if a.acked { "opacity-50" } else { "" })} { + td { + @if !a.acked { + span class="w-1.5 h-1.5 rounded-full block" + style={"background:" (severity_color(a.severity))} {} + } + } + td { (badges::severity_pill(a.severity)) } + td class="text-slate-200" { (&a.title) } + td class="font-mono text-[12px] text-slate-400 whitespace-nowrap" { + @if let Some(dep) = &a.deployment { (dep) } + @else if let Some(dev) = &a.device { (dev) } + @else { "system" } + } + td class="text-[12px] text-slate-500 tabular-nums" { (&a.at) } + td class="text-right" { + div class="inline-flex items-center gap-1" { + @if a.deployment.is_some() { + a href={"/deployment/" (a.deployment.as_deref().unwrap())} class="btn btn-ghost py-1" { + "Open" + } + } @else if a.device.is_some() && a.deployment.is_none() { + a href={"/device/" (a.device.as_deref().unwrap())} class="btn btn-ghost py-1" { + "Open" + } + } + @if !a.acked { + button + class="btn btn-ghost py-1" + hx-post={"/alerts/" (a.id) "/ack"} + hx-target="closest tr" + hx-swap="outerHTML" { + "Ack" + } + } + } + } + } + } + } + } + } + } + } +} + +pub fn alert_row(a: &Alert) -> Markup { + html! { + tr class={(if a.acked { "opacity-50" } else { "" })} { + td { + @if !a.acked { + span class="w-1.5 h-1.5 rounded-full block" + style={"background:" (severity_color(a.severity))} {} + } + } + td { (badges::severity_pill(a.severity)) } + td class="text-slate-200" { (&a.title) } + td class="font-mono text-[12px] text-slate-400 whitespace-nowrap" { + @if let Some(dep) = &a.deployment { (dep) } + @else if let Some(dev) = &a.device { (dev) } + @else { "system" } + } + td class="text-[12px] text-slate-500 tabular-nums" { (&a.at) } + td class="text-right" { + div class="inline-flex items-center gap-1" { + @if a.deployment.is_some() { + a href={"/deployment/" (a.deployment.as_deref().unwrap())} class="btn btn-ghost py-1" { + "Open" + } + } @else if a.device.is_some() && a.deployment.is_none() { + a href={"/device/" (a.device.as_deref().unwrap())} class="btn btn-ghost py-1" { + "Open" + } + } + @if !a.acked { + button + class="btn btn-ghost py-1" + hx-post={"/alerts/" (a.id) "/ack"} + hx-target="closest tr" + hx-swap="outerHTML" { + "Ack" + } + } + } + } + } + } +} + +fn severity_color(s: crate::service::AlertSeverity) -> &'static str { + match s { + crate::service::AlertSeverity::Critical => "var(--bad)", + crate::service::AlertSeverity::Warning => "var(--warn)", + crate::service::AlertSeverity::Info => "var(--info)", + } +} diff --git a/fleet/harmony-fleet-operator/src/frontend/views/badges.rs b/fleet/harmony-fleet-operator/src/frontend/views/badges.rs index f66a45ac..e2b1f991 100644 --- a/fleet/harmony-fleet-operator/src/frontend/views/badges.rs +++ b/fleet/harmony-fleet-operator/src/frontend/views/badges.rs @@ -1,32 +1,91 @@ use maud::{Markup, html}; -use crate::service::{DeploymentStatus, DeviceStatus}; +use crate::service::{AlertSeverity, DeploymentStatus, DeviceStatus}; pub fn device_status(s: DeviceStatus) -> Markup { - let (label, classes) = match s { - DeviceStatus::Healthy => ("healthy", "bg-emerald-900 text-emerald-300"), - DeviceStatus::Pending => ("pending", "bg-amber-900 text-amber-300"), - DeviceStatus::Stale => ("stale", "bg-rose-900 text-rose-300"), - DeviceStatus::Blacklisted => ("blacklisted", "bg-slate-800 text-slate-400"), - DeviceStatus::Unknown => ("unknown", "bg-slate-800 text-slate-500"), + let (color, bg, border, label) = match s { + DeviceStatus::Healthy => ("var(--ok)", "var(--ok-soft)", "rgba(52,211,153,0.25)", "healthy"), + DeviceStatus::Pending => ( + "var(--warn)", + "var(--warn-soft)", + "rgba(251,191,36,0.25)", + "pending", + ), + DeviceStatus::Stale => ( + "var(--bad)", + "var(--bad-soft)", + "rgba(251,113,133,0.25)", + "stale", + ), + DeviceStatus::Failing => ( + "var(--bad)", + "var(--bad-soft)", + "rgba(251,113,133,0.25)", + "failing", + ), + DeviceStatus::Blacklisted => ( + "#94a3b8", + "rgba(148,163,184,0.10)", + "rgba(148,163,184,0.25)", + "blacklisted", + ), + DeviceStatus::Unknown => ( + "#64748b", + "rgba(100,116,139,0.10)", + "rgba(100,116,139,0.25)", + "unknown", + ), }; - status_badge(label, classes) + status_badge(label, color, bg, border) } pub fn deployment_status(s: DeploymentStatus) -> Markup { - let (label, classes) = match s { - DeploymentStatus::Active => ("active", "bg-emerald-900 text-emerald-300"), - DeploymentStatus::Rolling => ("rolling", "bg-sky-900 text-sky-300"), - DeploymentStatus::Failing => ("failing", "bg-rose-900 text-rose-300"), - DeploymentStatus::Paused => ("paused", "bg-slate-800 text-slate-400"), + let (color, bg, border, label) = match s { + DeploymentStatus::Active => ("var(--ok)", "var(--ok-soft)", "#none", "active"), + DeploymentStatus::Rolling => ("var(--info)", "var(--info-soft)", "#none", "rolling"), + DeploymentStatus::Failing => ("var(--bad)", "var(--bad-soft)", "#none", "failing"), + DeploymentStatus::Paused => ("#94a3b8", "rgba(148,163,184,0.10)", "#none", "paused"), }; - status_badge(label, classes) + + let border_style = if border == "#none" { + "transparent" + } else { + border + }; + + status_badge(label, color, bg, border_style) } -fn status_badge(label: &str, classes: &str) -> Markup { +pub fn severity_pill(s: AlertSeverity) -> Markup { + let (color, bg, icon, label) = match s { + AlertSeverity::Critical => ("var(--bad)", "var(--bad-soft)", ICON_ERROR, "critical"), + AlertSeverity::Warning => ("var(--warn)", "var(--warn-soft)", ICON_WARNING, "warning"), + AlertSeverity::Info => ("var(--info)", "var(--info-soft)", ICON_INFO, "info"), + }; + html! { - span class={"inline-block rounded px-2 py-0.5 text-xs font-medium " (classes)} { + span class="inline-flex items-center gap-1.5 rounded-md px-1.5 py-0.5 text-[11px] font-medium" + style={"background:" (bg) "; color:" (color)} { + (PreEscaped(icon)) (label) } } } + +fn status_badge(label: &str, color: &str, bg: &str, border: &str) -> Markup { + html! { + span class="inline-flex items-center gap-1.5 rounded-md px-1.5 py-0.5 text-[11px] font-medium id-mono" + style={"background:" (bg) "; color:" (color) "; border:1px solid " (border)} { + span class="inline-block rounded-full" style={"width:5px; height:5px; background:" (color)} {} + (label) + } + } +} + +// ── Tiny inline SVGs for severity icons ──────────────────────────────── + +const ICON_ERROR: &str = r#""#; +const ICON_WARNING: &str = r#""#; +const ICON_INFO: &str = r#""#; + +use maud::PreEscaped; diff --git a/fleet/harmony-fleet-operator/src/frontend/views/dashboard.rs b/fleet/harmony-fleet-operator/src/frontend/views/dashboard.rs index 1e2a0170..1bae27bf 100644 --- a/fleet/harmony-fleet-operator/src/frontend/views/dashboard.rs +++ b/fleet/harmony-fleet-operator/src/frontend/views/dashboard.rs @@ -1,35 +1,369 @@ -use maud::{Markup, html}; +use maud::{Markup, PreEscaped, html}; -use crate::service::DashboardSummary; +use crate::frontend::views::badges; +use crate::service::DashboardDetail; -pub fn page(summary: &DashboardSummary) -> Markup { +// ── Inline icons ──────────────────────────────────────────────────────── +const ICON_PLUS: &str = r#""#; +const ICON_CHEVRON: &str = r#""#; +const ICON_LIST: &str = r#""#; +const ICON_ERROR: &str = r#""#; +const ICON_WARNING: &str = r#""#; + +pub fn page(d: &DashboardDetail) -> Markup { html! { - section { - h2 class="text-lg font-medium mb-4 text-slate-300" { "Devices" } - div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4" { - (card("Total", &summary.devices_total.to_string(), "text-slate-50")) - (card("Healthy", &summary.devices_healthy.to_string(), "text-emerald-400")) - (card("Pending", &summary.devices_pending.to_string(), "text-amber-400")) - (card("Stale", &summary.devices_stale.to_string(), "text-rose-400")) - (card("Blacklisted", &summary.devices_blacklisted.to_string(), "text-slate-500")) + div class="p-6 space-y-5" { + // Alert strip (if there are unacked alerts) + @if !d.active_alerts.is_empty() { + @let top = &d.active_alerts[0]; + @let more = d.active_alerts.len().saturating_sub(1); + (alert_strip(top, more)) } + + (health_row(d)) + (lower_row(d)) } - section { - h2 class="text-lg font-medium mb-4 text-slate-300" { "Deployments" } - div class="grid grid-cols-2 sm:grid-cols-3 gap-4" { - (card("Total", &summary.deployments_total.to_string(), "text-slate-50")) - (card("Active / Rolling", &summary.deployments_active.to_string(), "text-emerald-400")) - (card("Failing", &summary.deployments_failing.to_string(), "text-rose-400")) + } +} + +fn alert_strip(alert: &crate::service::Alert, more: usize) -> Markup { + let is_crit = matches!(alert.severity, crate::service::AlertSeverity::Critical); + let border = if is_crit { "rgba(244,63,94,0.3)" } else { "rgba(251,191,36,0.3)" }; + let bg = if is_crit { "rgba(244,63,94,0.06)" } else { "rgba(251,191,36,0.06)" }; + let icon_bg = if is_crit { "var(--bad-soft)" } else { "var(--warn-soft)" }; + let icon_color = if is_crit { "var(--bad)" } else { "var(--warn)" }; + + html! { + div class="card flex items-center gap-3 px-4 py-3" style={"border-color:" (border) "; background:" (bg)} { + span class="inline-flex items-center justify-center w-7 h-7 rounded-md" style={"background:" (icon_bg) "; color:" (icon_color)} { + @if is_crit { (PreEscaped(ICON_ERROR)) } @else { (PreEscaped(ICON_WARNING)) } + } + div class="min-w-0 flex-1" { + div class="text-[13px] text-slate-100 leading-snug truncate" { (&alert.title) } + div class="text-[11px] text-slate-500 mt-0.5" { + (&alert.at) + @if more > 0 { + span class="ml-2" { "\u{b7} " (more) " more alert" @if more > 1 { "s" } } + } + } + } + @if alert.deployment.is_some() { + a href={"/deployment/" (alert.deployment.as_deref().unwrap())} class="btn btn-ghost" { "Open deployment" } + } @else if alert.device.is_some() { + a href={"/device/" (alert.device.as_deref().unwrap())} class="btn btn-ghost" { "Open device" } + } + button + class="btn btn-ghost" + hx-post={"/alerts/" (alert.id) "/ack"} + hx-swap="none" + { "Ack" } + } + } +} + +fn health_row(d: &DashboardDetail) -> Markup { + let health_trend_svg = sparkline_svg(&d.health_trend, "var(--ok)", 180.0, 36.0, "ok"); + let ingest_trend_svg = sparkline_svg_u32(&d.ingest_trend, "var(--accent)", 240.0, 56.0, "ac"); + + html! { + div class="grid grid-cols-12 gap-4" { + // Big health card + div class="col-span-12 lg:col-span-5 card p-5 relative overflow-hidden" { + div class="flex items-start justify-between" { + div { + div class="section-title" { "Fleet Health" } + div class="mt-2 flex items-baseline gap-3" { + span class="text-[44px] font-semibold tracking-tight tabular-nums leading-none text-slate-50" { + (d.health_pct) "%" + } + span class="text-sm text-slate-400" { + "healthy across " (d.devices_total) " devices" + } + } + div class="mt-1.5 flex items-center gap-1.5 text-[11px] text-slate-500" { + span class="text-emerald-400" { "\u{25b2} 1.2%" } + span { "vs. 24h ago" } + } + } + div class="flex items-center gap-1.5 text-[11px] text-slate-400" { + span class="relative inline-flex w-1.5 h-1.5" { + span class="absolute inline-flex h-full w-full animate-ping rounded-full opacity-60" style="background:var(--ok)" {} + span class="relative inline-flex w-1.5 h-1.5 rounded-full" style="background:var(--ok)" {} + } + span { "live" } + } + } + + div class="mt-4" { + (segmented_progress( + &[ + (0u32, d.devices_healthy, "var(--ok)", "healthy"), + (0u32, d.devices_pending, "var(--warn)", "pending"), + (0u32, d.devices_failing, "var(--bad)", "failing"), + (0u32, d.devices_stale, "rgba(251,113,133,0.6)", "stale"), + (0u32, d.devices_blacklisted, "#475569", "blacklisted"), + (0u32, d.devices_unknown, "#334155", "unknown"), + ], + d.devices_total, + 6, + )) + } + + div class="mt-4 grid grid-cols-3 gap-x-6 gap-y-2 text-[12px]" { + (stat("Healthy", d.devices_healthy, "var(--ok)")) + (stat("Pending", d.devices_pending, "var(--warn)")) + (stat("Failing", d.devices_failing, "var(--bad)")) + (stat("Stale", d.devices_stale, "rgba(251,113,133,0.7)")) + (stat("Blacklisted", d.devices_blacklisted, "#94a3b8")) + (stat("Unknown", d.devices_unknown, "#64748b")) + } + + div class="absolute right-5 bottom-3 opacity-90 pointer-events-none" { + (PreEscaped(health_trend_svg)) + div class="text-[10px] text-slate-600 font-mono text-right mt-0.5" { "24h health" } + } + } + + // Deployment summary + div class="col-span-12 lg:col-span-4 card p-5" { + div class="flex items-center justify-between" { + div { + div class="section-title" { "Deployments" } + div class="mt-2 text-[28px] font-semibold text-slate-50 tabular-nums leading-none" { + (d.deployments_total) + } + div class="text-[12px] text-slate-500 mt-1" { + (d.rolling_count) " rolling out \u{b7} " (d.failing_count) " failing" + } + } + a href="/deployments" class="btn btn-ghost" { + (PreEscaped(ICON_PLUS)) " New deployment" + } + } + + div class="mt-5 space-y-1" { + @for dep in &d.top_deployments { + a href={"/deployment/" (dep.name)} class="w-full text-left flex items-center gap-3 py-1.5 px-1 rounded hover:bg-white/2.5" { + div class="flex-1 min-w-0" { + div class="flex items-center gap-2" { + span class="font-mono text-[12px] text-slate-200 truncate whitespace-nowrap" { (&dep.name) } + span class="font-mono text-[10px] text-slate-500 whitespace-nowrap shrink-0" { (&dep.version) } + } + div class="mt-1 flex items-center gap-2" { + div class="flex-1 max-w-[140px]" { + (segmented_progress( + &[ + (0u32, dep.healthy, "var(--ok)", "healthy"), + (0u32, dep.pending, "var(--warn)", "pending"), + (0u32, dep.failing, "var(--bad)", "failing"), + ], + dep.target, + 3, + )) + } + span class="text-[10px] text-slate-500 tabular-nums" { (dep.healthy) "/" (dep.target) } + } + } + (badges::deployment_status(dep.status)) + } + } + } + } + + // Ingest rate + div class="col-span-12 lg:col-span-3 card p-5" { + div class="section-title" { "Ingest rate" } + div class="mt-2 flex items-baseline gap-2" { + span class="text-[28px] font-semibold text-slate-50 tabular-nums leading-none" { (d.ingest_rate) } + span class="text-[12px] text-slate-500" { "k events/min" } + } + div class="mt-3" { + (PreEscaped(ingest_trend_svg)) + } + div class="flex justify-between text-[10px] text-slate-600 font-mono mt-1" { + span { "\u{2212}24h" } + span { "now" } + } } } } } -fn card(title: &str, value: &str, value_class: &str) -> Markup { +fn lower_row(d: &DashboardDetail) -> Markup { html! { - div class="rounded-lg border border-slate-800 bg-slate-900 p-4" { - div class="text-xs uppercase tracking-wide text-slate-400" { (title) } - div class={"mt-2 text-3xl font-semibold " (value_class)} { (value) } + div class="grid grid-cols-12 gap-4" { + // Needs attention + div class="col-span-12 lg:col-span-7 card" { + div class="flex items-center justify-between px-4 py-3 border-b" style="border-color:var(--border)" { + div class="flex items-center gap-2" { + span class="section-title" { "Needs attention" } + span class="text-[10px] text-slate-600 font-mono" { + (d.attention_devices.len()) " devices" + } + } + a href="/devices?status=failing" class="text-[11px] text-slate-400 hover:text-slate-100 flex items-center gap-1" { + "View all " (PreEscaped(ICON_CHEVRON)) + } + } + table class="tbl" { + thead { + tr { + th { "Device" } + th { "Status" } + th { "Deployment" } + th { "Last seen" } + th class="text-right" { "Action" } + } + } + tbody { + @for dev in &d.attention_devices { + tr class="cursor-pointer" + hx-get={"/device/" (dev.id)} + hx-target="closest main" + hx-push-url="true" { + td { + span class="font-mono text-slate-100 whitespace-nowrap" { (&dev.id) } + } + td { (badges::device_status(dev.status)) } + td class="text-slate-300 font-mono text-[12px] whitespace-nowrap" { + @if let Some(dep) = &dev.deployment { (dep) } + @else { "\u{2014}" } + } + td class="text-slate-500 text-[12px] tabular-nums" { + (time_ago(dev.minutes_ago)) + } + td class="text-right" { + button + class="btn btn-ghost py-1" + hx-get={"/devices/" (dev.id) "/logs"} + hx-target="#modal-root" + hx-swap="innerHTML" + onclick="event.stopPropagation();" { + (PreEscaped(ICON_LIST)) " Logs" + } + } + } + } + } + } + } + + // Activity feed + div class="col-span-12 lg:col-span-5 card" { + div class="flex items-center justify-between px-4 py-3 border-b" style="border-color:var(--border)" { + span class="section-title" { "Activity" } + span class="text-[10px] text-slate-600 font-mono" { "live" } + } + ul class="px-4 py-1 space-y-0" { + @for (i, a) in d.activity_feed.iter().enumerate() { + @let border = if i < d.activity_feed.len() - 1 { "border-b" } else { "" }; + li class={"flex items-start gap-3 py-2.5 text-[13px] " (border)} style="border-color:var(--border)" { + span class="font-mono text-[10px] text-slate-600 mt-1 w-10 shrink-0 tabular-nums" { (&a.at) } + span class="text-slate-400 leading-snug" { + span class={(if a.who == "system" { "text-slate-500" } else { "text-slate-200" })} { (&a.who) } + span { " " (a.verb) " " } + @if !a.target.is_empty() { + span class="font-mono text-slate-300 whitespace-nowrap" { (&a.target) } + } + } + } + } + } + } } } } + +fn stat(label: &str, value: u32, color: &str) -> Markup { + html! { + div class="flex items-center gap-1.5" { + span class="w-1.5 h-1.5 rounded-full" style={"background:" (color)} {} + span class="text-slate-500" { (label) } + span class="ml-auto font-mono text-slate-200 tabular-nums" { (value) } + } + } +} + +fn time_ago(minutes: i64) -> String { + if minutes < 1 { + "just now".into() + } else if minutes < 60 { + format!("{}m ago", minutes) + } else if minutes < 60 * 24 { + format!("{}h ago", minutes / 60) + } else { + format!("{}d ago", minutes / (60 * 24)) + } +} + +// ── Segmented progress bar ───────────────────────────────────────────── + +fn segmented_progress(segments: &[(u32, u32, &str, &str)], total: u32, height: u32) -> Markup { + html! { + div class="w-full rounded-full overflow-hidden progress-bg flex" style={"height:" (height) "px"} { + @for (_cum, val, color, _label) in segments { + @let width = if total > 0 { + (*val as f64 / total as f64) * 100.0 + } else { 0.0 }; + div class="h-full" style={"width:" (width) "%; background:" (color)} {} + } + } + } +} + +// ── Sparkline SVG generators ─────────────────────────────────────────── + +pub fn sparkline_svg(values: &[f64], color: &str, w: f64, h: f64, prefix: &str) -> String { + let max = values.iter().cloned().fold(0.0f64, f64::max).max(1.0); + let min = values.iter().cloned().fold(f64::MAX, f64::min).min(0.0); + let range = (max - min).max(1.0); + let step = w / (values.len().max(2) - 1) as f64; + let pts: Vec<(f64, f64)> = values + .iter() + .enumerate() + .map(|(i, &v)| (i as f64 * step, h - ((v - min) / range) * (h - 4.0) - 2.0)) + .collect(); + + let path = pts + .iter() + .enumerate() + .map(|(i, (x, y))| { + if i == 0 { + format!("M {:.1} {:.1}", x, y) + } else { + format!("L {:.1} {:.1}", x, y) + } + }) + .collect::>() + .join(" "); + + let area = format!("{} L {:.0} {:.0} L {:.0} {:.0} Z", path, w, h, 0.0, h); + let (lx, ly) = pts.last().copied().unwrap_or((0.0, 0.0)); + let gradient_id = format!("spark-{prefix}"); + + format!( + r#" + + + + + + + + +"#, + w = w, + h = h, + gid = gradient_id, + color = color, + area = area, + path = path, + lx = lx, + ly = ly, + ) +} + +fn sparkline_svg_u32(values: &[u32], color: &str, w: f64, h: f64, prefix: &str) -> String { + let floats: Vec = values.iter().map(|&v| v as f64).collect(); + sparkline_svg(&floats, color, w, h, prefix) +} diff --git a/fleet/harmony-fleet-operator/src/frontend/views/deployments.rs b/fleet/harmony-fleet-operator/src/frontend/views/deployments.rs index 0f128235..7e687d9d 100644 --- a/fleet/harmony-fleet-operator/src/frontend/views/deployments.rs +++ b/fleet/harmony-fleet-operator/src/frontend/views/deployments.rs @@ -1,39 +1,285 @@ -use maud::{Markup, html}; +use maud::{Markup, PreEscaped, html}; use crate::frontend::views::badges; -use crate::service::{DeploymentSummary, DeviceSummary}; +use crate::service::{DeploymentDetail, DeviceDetail, TaskGraph, TaskNode, TaskStatus}; -pub fn page(deployments: &[DeploymentSummary]) -> Markup { +// ── Inline icons ──────────────────────────────────────────────────────── +const ICON_PLUS: &str = r#""#; +const ICON_EXTERNAL: &str = r#""#; +const ICON_REFRESH: &str = r#""#; +const ICON_PLAY: &str = r#""#; +const ICON_PAUSE: &str = r#""#; +const ICON_ROLLBACK: &str = r#""#; +const ICON_DEPLOY: &str = r#""#; +const ICON_LIST: &str = r#""#; +const ICON_GRAPH: &str = r#""#; +const ICON_DRAG: &str = r#""#; +const ICON_MORE: &str = r#""#; +const ICON_CHECK: &str = r#""#; +const ICON_CLOSE: &str = r#""#; + +// ── Deployments list page ────────────────────────────────────────────── + +pub fn page(deployments: &[DeploymentDetail]) -> Markup { html! { - section { - div class="flex items-baseline gap-3 mb-4" { - h2 class="text-lg font-medium text-slate-300" { "Deployments" } - span class="text-xs text-slate-500" { (deployments.len()) " total" } + div class="p-6 space-y-4" { + div class="flex items-center gap-2" { + h2 class="text-[15px] font-semibold text-slate-200" { "All deployments" } + span class="text-[11px] text-slate-500" { "\u{b7} " (deployments.len()) } + div class="flex-1" {} + button class="btn btn-primary" { (PreEscaped(ICON_PLUS)) " New deployment" } } - div class="overflow-x-auto rounded-lg border border-slate-800" { - table class="min-w-full divide-y divide-slate-800 text-sm" { - thead class="bg-slate-900 text-xs uppercase tracking-wide text-slate-400" { + div class="grid grid-cols-1 lg:grid-cols-2 gap-4" { + @for d in deployments { + (deployment_card(d)) + } + } + style { (PreEscaped(r#"@keyframes roll-marquee { 0% { transform: translateX(-100%); } 100% { transform: translateX(400%); } }"#)) } + } + } +} + +fn deployment_card(d: &DeploymentDetail) -> Markup { + html! { + a href={"/deployment/" (d.name)} class="card text-left p-5 hover:border-slate-700 transition-colors relative overflow-hidden group block" { + div class="flex items-start justify-between gap-4" { + div class="min-w-0" { + div class="flex items-center gap-2" { + h3 class="font-mono text-[15px] text-slate-100 truncate" { (&d.name) } + span class="font-mono text-[11px] text-slate-500 whitespace-nowrap shrink-0" { (&d.version) } + } + div class="text-[11px] text-slate-500 mt-1" { + "Last updated " (d.updated_at) " \u{b7} by " + span class="text-slate-400" { (&d.author) } + } + } + (badges::deployment_status(d.status)) + } + + div class="mt-4 flex items-end justify-between gap-6" { + div class="flex-1 min-w-0" { + div class="flex items-baseline gap-2" { + span class="text-[26px] font-semibold text-slate-100 tabular-nums leading-none" { (d.healthy) } + span class="text-[12px] text-slate-500" { "/ " (d.target) " healthy" } + } + div class="mt-2" { + (segmented_progress(d, 5)) + } + div class="mt-2 flex gap-4 text-[11px] text-slate-500 font-mono" { + span { span style="color:var(--ok)" { "\u{25cf}" } " " (d.healthy) " healthy" } + @if d.pending > 0 { + span { span style="color:var(--warn)" { "\u{25cf}" } " " (d.pending) " pending" } + } + @if d.failing > 0 { + span { span style="color:var(--bad)" { "\u{25cf}" } " " (d.failing) " failing" } + } + } + } + div class="text-right shrink-0" { + div class="text-[10px] text-slate-600 font-mono uppercase tracking-wider" { "Tasks" } + div class="font-mono text-[12px] text-slate-300 mt-1" { "8 steps \u{b7} DAG" } + div class="mt-2 text-(--accent-fg) text-[11px] flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity" { + "Open " (PreEscaped(ICON_EXTERNAL)) + } + } + } + + @if d.status == crate::service::DeploymentStatus::Rolling { + span class="absolute top-0 left-0 right-0 h-0.5 overflow-hidden" { + span class="block h-full w-1/3" style="background:var(--info); animation:roll-marquee 2.4s linear infinite" {} + } + } + } + } +} + +// ── Deployment detail page ───────────────────────────────────────────── + +pub fn detail( + deployment: &DeploymentDetail, + devices: &[DeviceDetail], + task_graph: &TaskGraph, + task_view: &str, +) -> Markup { + let pct = if deployment.target > 0 { + ((deployment.healthy as f64 / deployment.target as f64) * 100.0).round() as u32 + } else { + 0 + }; + + html! { + div class="p-6 space-y-4" { + // Header + div class="card p-5" { + div class="flex items-start justify-between gap-6" { + div class="min-w-0" { + div class="flex items-center gap-3 flex-wrap" { + h1 class="text-[22px] font-semibold font-mono text-slate-50 truncate whitespace-nowrap" { + (&deployment.name) + } + (badges::deployment_status(deployment.status)) + span class="font-mono text-[11px] text-slate-500 whitespace-nowrap shrink-0" { + (&deployment.version) + } + } + div class="mt-2 flex items-center gap-x-5 gap-y-1 text-[12px] text-slate-500" { + span { span class="text-slate-600" { "Targets" } " " span class="text-slate-300 font-mono" { (deployment.target) " devices" } } + span { span class="text-slate-600" { "Updated" } " " span class="text-slate-300 tabular-nums" { (&deployment.updated_at) } } + span { span class="text-slate-600" { "By" } " " span class="text-slate-300" { (&deployment.author) } } + } + } + div class="flex items-center gap-2 shrink-0" { + button class="btn btn-ghost" { (PreEscaped(ICON_REFRESH)) " Reconcile" } + @if deployment.status == crate::service::DeploymentStatus::Paused { + button class="btn btn-ghost" { (PreEscaped(ICON_PLAY)) " Resume" } + } @else { + button class="btn btn-ghost" { (PreEscaped(ICON_PAUSE)) " Pause" } + } + button class="btn btn-ghost" { (PreEscaped(ICON_ROLLBACK)) " Rollback" } + button class="btn btn-primary" { (PreEscaped(ICON_DEPLOY)) " Roll out" } + } + } + + // Rollout progress + div class="mt-5 grid grid-cols-12 gap-5" { + div class="col-span-12 md:col-span-8" { + div class="flex items-baseline justify-between gap-3 mb-2" { + span class="text-[11px] text-slate-500 uppercase tracking-wider whitespace-nowrap" { + "Rollout progress" + } + span class="text-[12px] text-slate-300 font-mono tabular-nums whitespace-nowrap" { + (pct) "% complete" + } + } + (segmented_progress(deployment, 10)) + div class="mt-3 flex gap-6 text-[12px] text-slate-400" { + span class="flex items-center gap-1.5" { + span class="w-2 h-2 rounded-full" style="background:var(--ok)" {} + span class="font-mono tabular-nums" { (deployment.healthy) } + " healthy" + } + span class="flex items-center gap-1.5" { + span class="w-2 h-2 rounded-full" style="background:var(--warn)" {} + span class="font-mono tabular-nums" { (deployment.pending) } + " pending" + } + span class="flex items-center gap-1.5" { + span class="w-2 h-2 rounded-full" style="background:var(--bad)" {} + span class="font-mono tabular-nums" { (deployment.failing) } + " failing" + } + span class="flex items-center gap-1.5 text-slate-600" { + span class="w-2 h-2 rounded-full" style="background:#475569" {} + span class="font-mono tabular-nums" { + (deployment.target.saturating_sub(deployment.healthy + deployment.pending + deployment.failing)) + } + " idle" + } + } + } + div class="col-span-12 md:col-span-4" { + (PreEscaped(sparkline_svg(deployment))) + div class="text-[10px] text-slate-600 font-mono mt-1 text-right" { "Healthy devices \u{b7} 24h" } + } + } + } + + // Tabs + div class="flex items-center gap-1 border-b" style="border-color:var(--border)" { + button + class="px-3 py-2 text-[13px] font-medium relative text-slate-100" + hx-get={"/deployment/" (deployment.name) "?tab=overview"} + hx-target="#dep-tab-content" + hx-swap="innerHTML" { + "Overview" + span class="absolute left-0 right-0 -bottom-px h-0.5" style="background:var(--accent)" {} + } + button + class="px-3 py-2 text-[13px] font-medium relative text-slate-500 hover:text-slate-300" + hx-get={"/deployment/" (deployment.name) "?tab=devices"} + hx-target="#dep-tab-content" + hx-swap="innerHTML" { + "Devices (" (devices.len()) ")" + } + button + class="px-3 py-2 text-[13px] font-medium relative text-slate-500 hover:text-slate-300" + hx-get={"/deployment/" (deployment.name) "?tab=tasks"} + hx-target="#dep-tab-content" + hx-swap="innerHTML" { + "Task graph" + } + button + class="px-3 py-2 text-[13px] font-medium relative text-slate-500 hover:text-slate-300" + hx-get={"/deployment/" (deployment.name) "?tab=config"} + hx-target="#dep-tab-content" + hx-swap="innerHTML" { + "Config" + } + } + + div id="dep-tab-content" { + (overview_tab(task_graph, task_view, devices)) + } + } + } +} + +pub fn tab_content( + deployment: &DeploymentDetail, + devices: &[DeviceDetail], + task_graph: &TaskGraph, + task_view: &str, + tab: &str, +) -> Markup { + match tab { + "devices" => devices_tab(devices), + "tasks" => task_graph_view(task_graph, task_view), + "config" => config_tab(deployment), + _ => overview_tab(task_graph, task_view, devices), + } +} + +fn overview_tab(task_graph: &TaskGraph, task_view: &str, devices: &[DeviceDetail]) -> Markup { + html! { + div class="grid grid-cols-12 gap-4" { + div class="col-span-12 lg:col-span-7" { + (task_graph_view(task_graph, task_view)) + } + div class="col-span-12 lg:col-span-5" { + (per_device_grid(devices)) + } + } + } +} + +fn devices_tab(devices: &[DeviceDetail]) -> Markup { + html! { + div class="card card-flush mt-4" { + table class="tbl" { + thead { + tr { + th { "Device" } + th { "Status" } + th { "Region" } + th { "IP" } + th { "Firmware" } + th { "Last seen" } + th class="text-right" { "Action" } + } + } + tbody { + @for d in devices { tr { - th class="px-3 py-2 text-left font-medium" { "Name" } - th class="px-3 py-2 text-left font-medium" { "Status" } - th class="px-3 py-2 text-left font-medium" { "Health" } - } - } - tbody class="divide-y divide-slate-800 bg-slate-950" { - @for d in deployments { - tr { - td class="px-3 py-2 font-mono" { - a - href={"/deployment/" (d.name)} - class="text-slate-200 hover:text-orange-400 hover:underline" - { - (d.name) - } - } - td class="px-3 py-2" { (badges::deployment_status(d.status)) } - td class="px-3 py-2 text-slate-300" { - (d.healthy_devices) " / " (d.target_devices) " healthy" - } + td { + a href={"/device/" (d.id)} class="font-mono text-slate-100 hover:text-(--accent-fg) whitespace-nowrap" { (&d.id) } + } + td { (badges::device_status(d.status)) } + td { span class="text-[12px] text-slate-400 font-mono whitespace-nowrap" { (&d.region) } } + td { span class="font-mono text-[12px] text-slate-500 whitespace-nowrap" { @if let Some(ip) = &d.ip { (ip) } @else { "\u{2014}" } } } + td { span class="font-mono text-[11px] text-slate-500 whitespace-nowrap" { (&d.fw) } } + td { span class="text-[12px] text-slate-500 tabular-nums" { (time_ago(d.minutes_ago)) } } + td class="text-right" { + button class="text-slate-400 hover:text-slate-100 px-1.5 py-1" { (PreEscaped(ICON_MORE)) } } } } @@ -43,45 +289,245 @@ pub fn page(deployments: &[DeploymentSummary]) -> Markup { } } -pub fn detail(deployment: &DeploymentSummary, devices: &[DeviceSummary]) -> Markup { +fn config_tab(deployment: &DeploymentDetail) -> Markup { html! { - section { - div class="mb-4" { - a href="/deployments" class="text-sm text-slate-500 hover:text-slate-300" { "←" } + div class="card p-5 mt-4" { + div class="section-title mb-2" { "Deployment manifest" } + pre class="font-mono text-[12px] text-slate-300 leading-6 p-4 rounded" style="background:#050608; border:1px solid var(--border)" { + "apiVersion: harmony/v1\n" + "kind: Deployment\n" + "metadata:\n" + " name: " (deployment.name) "\n" + " version: " (deployment.version) "\n" + "spec:\n" + " target:\n" + " selector: tags has \"prod\"\n" + " count: " (deployment.target) "\n" + " strategy:\n" + " type: rolling\n" + " maxUnavailable: 10%\n" + " tasks:\n" + " - id: fetch_artifact\n" + " run: hf-agent pull oci://registry/" (deployment.name) ":" (deployment.version) "\n" + " - id: verify_signature\n" + " run: cosign verify --key /etc/harmony/pub.key\n" + " after: [fetch_artifact]\n" + " - id: install_deps\n" + " run: hf-agent apt install -y libsensor3 libcrypto3\n" + " after: [verify_signature]\n" + " - id: launch_services\n" + " run: systemctl restart sensord relayd\n" + " after: [install_deps]\n" + " - id: health_probe\n" + " run: hf-agent probe --timeout 30s\n" + " after: [launch_services]\n" } + } + } +} - div class="flex items-baseline gap-3 mb-4" { - h2 class="text-lg font-medium text-slate-300" { "Deployment" } - span class="font-mono text-xs text-slate-500" { (&deployment.name) } +fn per_device_grid(devices: &[DeviceDetail]) -> Markup { + html! { + div class="card" { + div class="flex items-center justify-between px-4 py-3 border-b" style="border-color:var(--border)" { + span class="section-title" { "Per-device rollout" } + span class="text-[10px] text-slate-600 font-mono" { (devices.len()) " devices" } } + div class="p-4 grid grid-cols-10 gap-1.5" { + @for d in devices { + @let c = device_status_color(d.status); + a + href={"/device/" (&d.id)} + title={(&d.id) " \u{b7} " (d.status.label())} + class="aspect-square rounded-[3px] hover:ring-2 transition-all" + style={"background:" (c) "; opacity:" (if d.status == crate::service::DeviceStatus::Pending { "0.5" } else { "1" }) "; box-shadow:inset 0 0 0 1px rgba(0,0,0,0.2)"} { + } + } + } + div class="border-t px-4 py-2.5 flex items-center gap-3 text-[10px] text-slate-500 font-mono" style="border-color:var(--border)" { + span class="flex items-center gap-1" { span class="w-2 h-2 rounded-sm" style="background:var(--ok)" {} " healthy" } + span class="flex items-center gap-1" { span class="w-2 h-2 rounded-sm" style="background:var(--warn)" {} " pending" } + span class="flex items-center gap-1" { span class="w-2 h-2 rounded-sm" style="background:var(--bad)" {} " failing" } + span class="flex items-center gap-1" { span class="w-2 h-2 rounded-sm" style="background:rgba(251,113,133,0.6)" {} " stale" } + } + } + } +} - div class="rounded-lg border border-slate-800 bg-slate-950 p-5" { - dl class="grid gap-4 text-sm sm:grid-cols-[12rem_1fr]" { - dt class="text-slate-500" { "Name" } - dd class="font-mono text-slate-200" { (&deployment.name) } +fn device_status_color(status: crate::service::DeviceStatus) -> &'static str { + match status { + crate::service::DeviceStatus::Healthy => "var(--ok)", + crate::service::DeviceStatus::Pending => "var(--warn)", + crate::service::DeviceStatus::Failing => "var(--bad)", + crate::service::DeviceStatus::Stale => "rgba(251,113,133,0.6)", + crate::service::DeviceStatus::Blacklisted => "#475569", + crate::service::DeviceStatus::Unknown => "#475569", + } +} - dt class="text-slate-500" { "Status" } - dd { (badges::deployment_status(deployment.status)) } +// ── Task graph view ──────────────────────────────────────────────────── - dt class="text-slate-500" { "Health" } - dd class="text-slate-300" { - (deployment.healthy_devices) " / " (deployment.target_devices) " healthy" +fn task_graph_view(task_graph: &TaskGraph, view: &str) -> Markup { + let done_count = task_graph.nodes.iter().filter(|n| n.status == TaskStatus::Done).count(); + html! { + div class="card overflow-hidden" { + div class="flex items-center justify-between px-4 py-3 border-b" style="border-color:var(--border)" { + div class="flex items-center gap-2" { + span class="section-title" { "Task execution" } + span class="text-[10px] text-slate-600 font-mono" { + (done_count) " / " (task_graph.nodes.len()) " complete" + } + } + div class="flex items-center gap-1 p-0.5 rounded-md" style="background:rgba(148,163,184,0.06)" { + a + href={"?task_view=linear"} + class={"px-2 py-1 rounded text-[11px] flex items-center gap-1.5 " + (if view == "linear" { "text-slate-100" } else { "text-slate-500 hover:text-slate-300" })} + style={(if view == "linear" { "background:rgba(148,163,184,0.08)" } else { "background:transparent" })} { + (PreEscaped(ICON_LIST)) " Linear" + } + a + href={"?task_view=dag"} + class={"px-2 py-1 rounded text-[11px] flex items-center gap-1.5 " + (if view == "dag" { "text-slate-100" } else { "text-slate-500 hover:text-slate-300" })} + style={(if view == "dag" { "background:rgba(148,163,184,0.08)" } else { "background:transparent" })} { + (PreEscaped(ICON_GRAPH)) " DAG" } } } - div class="mt-6 rounded-lg border border-slate-800 bg-slate-950" { - div class="border-b border-slate-800 px-5 py-3" { - h3 class="text-sm font-medium text-slate-300" { "Target devices" } + @if view == "linear" { + ul class="p-3 space-y-1" { + @for (i, n) in task_graph.nodes.iter().enumerate() { + (task_row(n, i)) + } } + } @else { + (dag_view(task_graph)) + } + } + } +} - table class="min-w-full divide-y divide-slate-800 text-sm" { - tbody class="divide-y divide-slate-800" { - @for device in devices { - tr { - td class="px-5 py-2 font-mono text-slate-200" { (&device.id) } - td class="px-5 py-2 text-slate-400" { (badges::device_status(device.status)) } +fn task_row(node: &TaskNode, index: usize) -> Markup { + let status = node.status; + let c = match status { + TaskStatus::Done => "var(--ok)", + TaskStatus::Running => "var(--accent)", + TaskStatus::Failed => "var(--bad)", + TaskStatus::Pending => "#475569", + }; + + html! { + li class="flex items-center gap-3 px-2 py-2 rounded hover:bg-white/2 group" { + button class="text-slate-700 hover:text-slate-400 cursor-grab" title="Drag to reorder" { + (PreEscaped(ICON_DRAG)) + } + span class="font-mono text-[10px] text-slate-600 w-5 tabular-nums" { + (format!("{:02}", index + 1)) + } + span class="relative inline-flex w-6 h-6 items-center justify-center rounded-full" + style={"background:" (c) "22; border:1px solid " (c) "55"} { + @if status == TaskStatus::Done { + span style={"color:" (c)} { (PreEscaped(ICON_CHECK)) } + } @else if status == TaskStatus::Running { + span class="absolute inset-0 rounded-full animate-ping opacity-50" style={"background:" (c)} {} + span class="relative w-2 h-2 rounded-full" style={"background:" (c)} {} + } @else if status == TaskStatus::Failed { + span style={"color:" (c)} { (PreEscaped(ICON_CLOSE)) } + } @else { + span class="w-1.5 h-1.5 rounded-full" style={"background:" (c)} {} + } + } + div class="flex-1 min-w-0" { + div class="text-[13px] text-slate-100" { (&node.label) } + div class="text-[10px] text-slate-600 font-mono" { (&node.id) } + } + span class="text-[11px] font-mono text-slate-500 tabular-nums" { (&node.duration) } + span class="text-[10px] uppercase tracking-wider font-medium tabular-nums" style={"color:" (c)} { + (status_label(status)) + } + } + } +} + +fn dag_view(task_graph: &TaskGraph) -> Markup { + let col_w = 132; + let row_h = 92; + let pad = 24; + let cols = task_graph.positions.values().map(|p| p.0).max().unwrap_or(0) + 1; + let rows = task_graph.positions.values().map(|p| p.1).max().unwrap_or(0) + 1; + let total_w = cols * col_w + pad * 2; + let total_h = rows * row_h + pad * 2; + + let pos = |id: &str| -> (usize, usize) { + if let Some(&(c, r)) = task_graph.positions.get(id) { + (pad + c * col_w + col_w / 2 - 50, pad + r * row_h) + } else { + (pad, pad) + } + }; + + let edges_svg: String = task_graph + .edges + .iter() + .map(|(from, to)| { + let (fx, fy) = pos(from); + let (tx, ty) = pos(to); + let x1 = fx + 100; + let y1 = fy + 28; + let x2 = tx; + let y2 = ty + 28; + let cx = (x1 + x2) / 2; + let node = task_graph.nodes.iter().find(|n| &n.id == to); + let stroke = match node.map(|n| n.status) { + Some(TaskStatus::Done) => "rgba(52,211,153,0.45)", + Some(TaskStatus::Running) => "rgba(249,115,22,0.55)", + _ => "rgba(148,163,184,0.25)", + }; + format!( + r#""# + ) + }) + .collect::>() + .join("\n"); + + html! { + div class="p-3 overflow-auto" style="min-height:300px" { + style { (PreEscaped(r#"@keyframes draw { from { stroke-dashoffset: 1; } to { stroke-dashoffset: 0; } }"#)) } + div class="relative" style={"width:" (total_w) "px; height:" (total_h) "px"} { + svg class="absolute inset-0" width=(total_w) height=(total_h) style="pointer-events:none" { + defs { + marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse" { + path d="M 0 0 L 10 5 L 0 10 z" fill="rgba(148,163,184,0.45)" {} + } + } + (PreEscaped(&edges_svg)) + } + @for n in &task_graph.nodes { + @let (x, y) = pos(&n.id); + @let c = match n.status { + TaskStatus::Done => "var(--ok)", + TaskStatus::Running => "var(--accent)", + TaskStatus::Failed => "var(--bad)", + TaskStatus::Pending => "#475569", + }; + div class="absolute rounded-md px-2.5 py-2 select-none" + style={"left:" (x) "px; top:" (y) "px; width:100px; height:56px; background:var(--bg-elev-2); border:1px solid " (c) "55; box-shadow:0 0 0 1px rgba(255,255,255,0.02) inset"} { + div class="flex items-center gap-1.5" { + span class="relative inline-flex w-2 h-2 items-center justify-center" { + @if n.status == TaskStatus::Running { + span class="absolute inset-0 rounded-full animate-ping" style={"background:" (c) "; opacity:0.5"} {} + } + span class="w-2 h-2 rounded-full" style={"background:" (c)} {} } + span class="text-[10px] font-mono uppercase tracking-wider" style={"color:" (c)} { + (status_label(n.status)) + } + } + div class="text-[11px] text-slate-100 leading-tight mt-1 line-clamp-2" { (&n.label) } + div class="absolute right-2 bottom-1 text-[9px] font-mono text-slate-600 tabular-nums" { + (&n.duration) } } } @@ -89,3 +535,59 @@ pub fn detail(deployment: &DeploymentSummary, devices: &[DeviceSummary]) -> Mark } } } + +// ── Helpers ──────────────────────────────────────────────────────────── + +fn segmented_progress(d: &DeploymentDetail, height: u32) -> Markup { + html! { + div class="w-full rounded-full overflow-hidden progress-bg flex" style={"height:" (height) "px"} { + @if d.healthy > 0 { + div class="h-full" style={"width:" ((d.healthy as f64 / d.target as f64) * 100.0) "%; background:var(--ok)"} {} + } + @if d.pending > 0 { + div class="h-full" style={"width:" ((d.pending as f64 / d.target as f64) * 100.0) "%; background:var(--warn)"} {} + } + @if d.failing > 0 { + div class="h-full" style={"width:" ((d.failing as f64 / d.target as f64) * 100.0) "%; background:var(--bad)"} {} + } + } + } +} + +fn sparkline_svg(deployment: &DeploymentDetail) -> String { + let w = 300.0; + let h = 48.0; + let end = deployment.healthy as f64; + let range = deployment.target as f64 * 0.4; + let values: Vec = (0..24) + .map(|i| { + end - range / 2.0 + + (i as f64 / 3.0).sin() * range / 3.0 + + ((i as f64 * 7.3).sin() * 2.0) + }) + .collect(); + super::dashboard::sparkline_svg(&values, "var(--accent)", w, h, "dep") +} + +fn time_ago(minutes: i64) -> String { + if minutes < 1 { + "just now".into() + } else if minutes < 60 { + format!("{}m ago", minutes) + } else if minutes < 60 * 24 { + format!("{}h ago", minutes / 60) + } else { + format!("{}d ago", minutes / (60 * 24)) + } +} + +fn status_label(s: TaskStatus) -> &'static str { + match s { + TaskStatus::Done => "done", + TaskStatus::Running => "running", + TaskStatus::Pending => "pending", + TaskStatus::Failed => "failed", + } +} + + diff --git a/fleet/harmony-fleet-operator/src/frontend/views/devices.rs b/fleet/harmony-fleet-operator/src/frontend/views/devices.rs index 88676a9c..3116c110 100644 --- a/fleet/harmony-fleet-operator/src/frontend/views/devices.rs +++ b/fleet/harmony-fleet-operator/src/frontend/views/devices.rs @@ -1,100 +1,518 @@ use maud::{Markup, PreEscaped, html}; use crate::frontend::views::badges; -use crate::service::{DeviceStatus, DeviceSummary}; +use crate::service::{DeviceDetail, DeviceStatus}; + +// ── Inline icons ──────────────────────────────────────────────────────── +const ICON_SEARCH: &str = r#""#; +const ICON_CHEVRON_DOWN: &str = r#""#; +const ICON_LIST: &str = r#""#; +const ICON_MORE: &str = r#""#; +const ICON_POWER: &str = r#""#; +const ICON_PAUSE: &str = r#""#; +const ICON_BAN: &str = r#""#; +const ICON_EXPAND: &str = r#""#; +const ICON_EXTERNAL: &str = r#""#; +const ICON_REFRESH: &str = r#""#; +const ICON_COPY: &str = r#""#; + +// ── Devices list page ────────────────────────────────────────────────── + +pub fn page(devices: &[DeviceDetail], regions: &[String], deployments: &[String], status_filter: Option, deployment_filter: Option<&str>, region_filter: Option<&str>, search: Option<&str>) -> Markup { + let total = devices.len(); + let all_regions: Vec<&str> = regions.iter().map(|s| s.as_str()).collect(); -pub fn page(devices: &[DeviceSummary]) -> Markup { html! { - section { - div class="flex items-baseline gap-3 mb-4" { - h2 class="text-lg font-medium text-slate-300" { "Devices" } - span class="text-xs text-slate-500" { (devices.len()) " total" } - } - div class="overflow-x-auto rounded-lg border border-slate-800" { - table class="min-w-full divide-y divide-slate-800 text-sm" { - thead class="bg-slate-900 text-xs uppercase tracking-wide text-slate-400" { - tr { - th class="px-3 py-2 text-left font-medium" { "ID" } - th class="px-3 py-2 text-left font-medium" { "Status" } - th class="px-3 py-2 text-left font-medium" { "Deployment" } - th class="px-3 py-2 text-left font-medium" { "IP" } - th class="px-3 py-2 text-left font-medium" { "Last seen" } - th class="px-3 py-2 text-right font-medium" { "Actions" } + div class="p-6 space-y-4" { + div class="flex items-center gap-2 flex-wrap" { + @for (k, label) in [("all", "All"), ("healthy", "Healthy"), ("pending", "Pending"), ("failing", "Failing"), ("stale", "Stale"), ("blacklisted", "Blacklisted")].iter() { + @let active = match &status_filter { + None => *k == "all", + Some(s) => k == &s.label(), + }; + a href={"/devices?status=" (k)} class={(if active { "chip active" } else { "chip" })} { + span { (label) } + } + } + div class="w-px h-5 mx-1" style="background:var(--border-strong)" {} + form class="relative" hx-get="/devices" hx-target="body" hx-push-url="true" { + @if let Some(s) = status_filter { + input type="hidden" name="status" value=(s.label()); + } + select + name="deployment" + class="input pl-3 pr-8 appearance-none font-mono text-[12px]" + style="padding-left:10px" + onchange="this.form.requestSubmit()" { + option value="" selected[deployment_filter.is_none()] { "All deployments" } + @for dep in deployments { + option value=(dep.as_str()) selected[deployment_filter == Some(dep.as_str())] { (dep) } } } - tbody id="device-rows" class="divide-y divide-slate-800 bg-slate-950" { - @for device in devices { - (row(device)) + span class="absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none text-slate-500" { + (PreEscaped(ICON_CHEVRON_DOWN)) + } + } + form class="relative" hx-get="/devices" hx-target="body" hx-push-url="true" { + @if let Some(s) = status_filter { + input type="hidden" name="status" value=(s.label()); + } + @if let Some(d) = deployment_filter { + input type="hidden" name="deployment" value=(d); + } + select + name="region" + class="input pl-3 pr-8 appearance-none font-mono text-[12px]" + style="padding-left:10px" + onchange="this.form.requestSubmit()" { + option value="" selected[region_filter.is_none()] { "All regions" } + @for r in &all_regions { + option value=(r) selected[region_filter == Some(r)] { (r) } + } + } + span class="absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none text-slate-500" { + (PreEscaped(ICON_CHEVRON_DOWN)) + } + } + form class="relative flex-1 max-w-[280px] ml-auto" hx-get="/devices" hx-target="body" hx-push-url="true" { + @if let Some(s) = status_filter { + input type="hidden" name="status" value=(s.label()); + } + span class="absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-500" { + (PreEscaped(ICON_SEARCH)) + } + @let search_val = search.unwrap_or(""); + input + class="input w-full" + type="text" + name="search" + placeholder="Filter by id, ip, tag\u{2026}" + value=(search_val) + hx-get="/devices" + hx-trigger="keyup changed delay:300ms" + hx-target="body" + hx-push-url="true" + hx-include="closest form"; + } + } + + div class="card card-flush overflow-hidden" id="device-table-wrapper" { + div class="overflow-x-auto" style="max-height:calc(100vh - 240px)" { + table class="tbl" { + thead class="sticky top-0 z-10" { + tr { + th style="width:36px" {} + th { "Device ID" } + th { "Status" } + th { "Deployment" } + th { "Region" } + th { "IP" } + th { "Firmware" } + th { "Last seen" } + th class="text-right" { "Action" } + } + } + tbody { + @for d in devices { + tr hx-get={"/device/" (d.id)} hx-target="body" hx-push-url="true" class="cursor-pointer" { + td { + input + type="checkbox" + class="accent-(--accent) w-3.5 h-3.5 rounded" + onclick="event.stopPropagation()"; + } + td { + span class="font-mono text-slate-100 hover:text-(--accent-fg) hover:underline underline-offset-2 whitespace-nowrap" { + (&d.id) + } + } + td { (badges::device_status(d.status)) } + td { + @if let Some(dep) = &d.deployment { + span class="font-mono text-[12px] text-slate-300 whitespace-nowrap" { (dep) } + } @else { + span class="text-slate-700" { "\u{2014}" } + } + } + td { + span class="text-[12px] text-slate-400 font-mono whitespace-nowrap" { (&d.region) } + } + td { + span class="font-mono text-[12px] text-slate-500 whitespace-nowrap" { + @if let Some(ip) = &d.ip { (ip) } @else { "\u{2014}" } + } + } + td { + span class="font-mono text-[11px] text-slate-500 whitespace-nowrap" { (&d.fw) } + } + td { + span class="text-[12px] text-slate-500 tabular-nums" { (time_ago(d.minutes_ago)) } + } + td class="text-right" { + div class="inline-flex items-center gap-1" { + button + class="text-slate-400 hover:text-slate-100 px-1.5 py-1 rounded hover:bg-white/4" + title="Quick logs" + hx-get={"/devices/" (d.id) "/logs"} + hx-target="#modal-root" + hx-swap="innerHTML" + onclick="event.stopPropagation()" { + (PreEscaped(ICON_LIST)) + } + button + class="text-slate-400 hover:text-slate-100 px-1.5 py-1 rounded hover:bg-white/4" + title="More" + onclick="event.stopPropagation()" { + (PreEscaped(ICON_MORE)) + } + } + } + } + } + @if devices.is_empty() { + tr { + td colspan="9" class="text-center py-12 text-slate-500 text-[13px]" { + "No devices match these filters" + } + } + } } } } + div class="flex items-center justify-between px-4 py-2.5 border-t text-[12px] text-slate-500" style="border-color:var(--border)" { + span { + "Showing " span class="text-slate-300 tabular-nums" { (devices.len()) } + " of " span class="text-slate-300 tabular-nums" { (total) } " devices" + } + } } - div id="modal-root" {} } } } -/// Single row — also the response shape for `POST /devices/:id/blacklist`, -/// so HTMX can swap a row in place after a mutation. -pub fn row(d: &DeviceSummary) -> Markup { +// ── Device detail page ───────────────────────────────────────────────── + +pub fn detail(device: &DeviceDetail, deployment_version: Option<&str>) -> Markup { html! { - tr id={"device-" (d.id)} { - td class="px-3 py-2 font-mono" { + div class="p-6 space-y-4" { + // Header + div class="card p-5" { + div class="flex items-start justify-between gap-6" { + div class="min-w-0" { + div class="flex items-center gap-3 flex-wrap" { + h1 class="text-[22px] font-semibold font-mono text-slate-50 truncate whitespace-nowrap" { (&device.id) } + (badges::device_status(device.status)) + @for t in &device.tags { + span class="text-[10px] font-mono text-slate-400 px-1.5 py-0.5 rounded" style="background:rgba(148,163,184,0.06); border:1px solid var(--border)" { + "#" (t) + } + } + } + div class="mt-2 flex flex-wrap items-center gap-x-5 gap-y-1 text-[12px] text-slate-500" { + span { span class="text-slate-600" { "Model" } " " span class="text-slate-300 font-mono" { (&device.model) } } + span { span class="text-slate-600" { "Region" } " " span class="text-slate-300 font-mono" { (&device.region) } } + span { + span class="text-slate-600" { "IP" } " " + span class="text-slate-300 font-mono" { @if let Some(ip) = &device.ip { (ip) } @else { "\u{2014}" } } + } + span { span class="text-slate-600" { "Firmware" } " " span class="text-slate-300 font-mono" { (&device.fw) } } + span { span class="text-slate-600" { "Last seen" } " " span class="text-slate-300 tabular-nums" { (time_ago(device.minutes_ago)) } } + } + } + div class="flex items-center gap-2 shrink-0" { + button class="btn btn-ghost" { (PreEscaped(ICON_REFRESH)) " Reconcile" } + button class="btn btn-ghost" { (PreEscaped(ICON_POWER)) " Restart" } + button class="btn btn-ghost" { (PreEscaped(ICON_PAUSE)) " Suspend" } + @if device.status != DeviceStatus::Blacklisted { + button + class="btn btn-danger" + hx-post={"/devices/" (device.id) "/blacklist"} + hx-confirm={"Blacklist " (device.id) "?"} + hx-target="body" { + (PreEscaped(ICON_BAN)) " Blacklist" + } + } + } + } + } + + // Tab bar + div class="flex items-center gap-1 border-b" style="border-color:var(--border)" { button - type="button" - class="text-slate-200 hover:text-orange-400 hover:underline" - hx-get={"/devices/" (d.id) "/logs"} + class="px-3 py-2 text-[13px] font-medium relative text-slate-100" + hx-get={"/device/" (device.id) "?tab=overview"} + hx-target="#device-tab-content" + hx-swap="innerHTML" { + "Overview" + span class="absolute left-0 right-0 -bottom-px h-0.5" style="background:var(--accent)" {} + } + button + class="px-3 py-2 text-[13px] font-medium relative text-slate-500 hover:text-slate-300" + hx-get={"/device/" (device.id) "?tab=logs"} + hx-target="#device-tab-content" + hx-swap="innerHTML" { + "Logs" + } + button + class="px-3 py-2 text-[13px] font-medium relative text-slate-500 hover:text-slate-300" + hx-get={"/device/" (device.id) "?tab=history"} + hx-target="#device-tab-content" + hx-swap="innerHTML" { + "Deployment history" + } + button + class="px-3 py-2 text-[13px] font-medium relative text-slate-500 hover:text-slate-300" + hx-get={"/device/" (device.id) "?tab=config"} + hx-target="#device-tab-content" + hx-swap="innerHTML" { + "Config" + } + div class="flex-1" {} + button + class="btn btn-ghost mb-1" + hx-get={"/devices/" (device.id) "/logs"} hx-target="#modal-root" - hx-swap="innerHTML" - { - (d.id) + hx-swap="innerHTML" { + (PreEscaped(ICON_EXPAND)) " Pop-out logs" } } - td class="px-3 py-2" { (badges::device_status(d.status)) } - td class="px-3 py-2 text-slate-300" { - @if let Some(deployment) = &d.deployment { (deployment) } - @else { span class="text-slate-600" { "—" } } + + div id="device-tab-content" { + (overview_tab(device, deployment_version)) } - td class="px-3 py-2 font-mono text-slate-400" { - @if let Some(ip) = &d.ip { (ip) } - @else { span class="text-slate-600" { "—" } } + } + } +} + +pub fn tab_content(device: &DeviceDetail, tab: &str, deployment_version: Option<&str>) -> Markup { + match tab { + "logs" => logs_tab(device), + "history" => history_tab(device, deployment_version), + "config" => config_tab(device, deployment_version), + _ => overview_tab(device, deployment_version), + } +} + +fn overview_tab(device: &DeviceDetail, deployment_version: Option<&str>) -> Markup { + html! { + div class="grid grid-cols-12 gap-4" { + div class="col-span-12 lg:col-span-8 space-y-4" { + // Metrics + div class="card p-5" { + div class="section-title mb-3" { "Metrics (last hour)" } + div class="grid grid-cols-3 gap-5" { + (metric_card("CPU", &format!("{}%", device.cpu), "var(--accent)", device.cpu)) + (metric_card("Memory", &format!("{}%", device.mem), "var(--info)", device.mem)) + (metric_card("Uptime", &format!("{}d {}h", device.uptime_h / 24, device.uptime_h % 24), "var(--ok)", 78)) + } + } + + // Recent logs preview + div class="card" { + div class="flex items-center justify-between px-4 py-3 border-b" style="border-color:var(--border)" { + span class="section-title" { "Recent logs" } + button + class="text-[11px] text-slate-400 hover:text-slate-100 flex items-center gap-1" + hx-get={"/devices/" (device.id) "/logs"} + hx-target="#modal-root" + hx-swap="innerHTML" { + "Open full " (PreEscaped(ICON_EXTERNAL)) + } + } + div class="font-mono text-[11.5px] leading-6 px-4 py-2 max-h-[200px] overflow-auto" style="background:#050608" { + @for i in 0..7 { + (log_line("info", &format!("07:{}:0{}", 28 + i, (i * 3) % 10), &device.id, &format!("mock log entry #{}", i + 1))) + } + } + } } - td class="px-3 py-2 text-slate-400" { - (d.last_seen.format("%Y-%m-%d %H:%M:%S").to_string()) " UTC" - } - td class="px-3 py-2 text-right" { - @if d.status != DeviceStatus::Blacklisted { - button - class="rounded bg-rose-700 hover:bg-rose-600 px-2 py-1 text-xs font-medium" - hx-post={"/devices/" (d.id) "/blacklist"} - hx-target={"#device-" (d.id)} - hx-swap="outerHTML" - hx-confirm={"Blacklist " (d.id) "?"} - { "Blacklist" } - } @else { - span class="text-xs text-slate-500" { "blacklisted" } + + // Sidebar + div class="col-span-12 lg:col-span-4 space-y-4" { + // Current deployment + div class="card p-5" { + div class="section-title mb-3" { "Current deployment" } + @if let Some(dep_name) = &device.deployment { + a href={"/deployment/" (dep_name)} class="w-full text-left block" { + div class="font-mono text-slate-100 text-[14px] whitespace-nowrap" { (dep_name) } + div class="text-[11px] text-slate-500 mt-0.5" { + @if let Some(v) = deployment_version { (v) } + } + div class="mt-3 text-[11px] text-(--accent-fg) flex items-center gap-1" { + "Open deployment " (PreEscaped(ICON_EXTERNAL)) + } + } + } @else { + div class="text-[12px] text-slate-500" { "No deployment assigned" } + } + } + + // Identity + div class="card p-5" { + div class="section-title mb-3" { "Identity" } + (definition("Device ID", &device.id, true, true)) + (definition("MAC", "b8:27:eb:42:0a:1f", true, false)) + (definition("Agent", &format!("harmony-agent {}", device.fw), true, false)) + (definition("Enrolled", "2025-11-14 09:22 UTC", false, false)) + (definition("Region", &device.region, true, false)) + } + + // Activity + div class="card p-5" { + div class="section-title mb-3" { "Activity" } + ul class="space-y-2.5 text-[12px]" { + li class="flex gap-3" { + span class="font-mono text-slate-600 tabular-nums shrink-0" { "07:24" } + span class="text-slate-400" { span class="text-slate-200" { "r.tarzalt" } " triggered reconcile" } + } + li class="flex gap-3" { + span class="font-mono text-slate-600 tabular-nums shrink-0" { "06:51" } + span class="text-slate-400" { + "deployment " + span class="font-mono text-slate-300" { @if let Some(dep) = &device.deployment { (dep) } @else { "edge-gateway" } } + " applied" + } + } + li class="flex gap-3" { + span class="font-mono text-slate-600 tabular-nums shrink-0" { "04:12" } + span class="text-slate-400" { "agent restarted (reason: signal=15)" } + } + } } } } } } +fn logs_tab(device: &DeviceDetail) -> Markup { + html! { + div class="card overflow-hidden mt-4" { + div class="flex items-center gap-2 px-4 py-2.5 border-b" style="border-color:var(--border)" { + span class="relative flex w-1.5 h-1.5" { + span class="absolute inline-flex h-full w-full animate-ping rounded-full opacity-60" style="background:var(--accent)" {} + span class="relative inline-flex w-1.5 h-1.5 rounded-full" style="background:var(--accent)" {} + } + span class="text-[11px] font-mono text-slate-400" { "streaming" } + span class="text-[11px] text-slate-600 font-mono" { "\u{b7} live" } + div class="flex-1" {} + div class="flex items-center gap-1" { + span class="chip active" { "all" } + span class="chip" { "info" } + span class="chip" { "warn" } + span class="chip" { "error" } + span class="chip" { "debug" } + } + button class="btn btn-ghost py-1" { (PreEscaped(ICON_PAUSE)) " Pause" } + } + div + class="font-mono text-[11.5px] leading-6 px-4 py-2 overflow-auto" + style="background:#050608; height:520px" + hx-ext="sse" + sse-connect={"/devices/" (device.id) "/logs/stream"} + sse-swap="log" + hx-swap="beforeend" { + div class="px-0 py-px italic text-slate-700" { "\u{2014} connecting \u{2014}" } + } + } + } +} + +fn history_tab(device: &DeviceDetail, _deployment_version: Option<&str>) -> Markup { + let dep_name = device.deployment.as_deref().unwrap_or("edge-gateway"); + html! { + div class="card mt-4" { + table class="tbl" { + thead { + tr { + th { "Deployment" } + th { "Version" } + th { "Outcome" } + th { "Applied" } + th { "Duration" } + } + } + tbody { + tr { + td class="font-mono text-slate-200 whitespace-nowrap" { (dep_name) } + td class="font-mono text-[12px] whitespace-nowrap" { "v2.14.1" } + td { (badges::device_status(DeviceStatus::Healthy)) } + td class="text-slate-400 text-[12px]" { "2026-05-19 04:12" } + td class="font-mono text-[12px] text-slate-500" { "42s" } + } + tr { + td class="font-mono text-slate-200 whitespace-nowrap" { (dep_name) } + td class="font-mono text-[12px] whitespace-nowrap" { "v2.13.4" } + td { (badges::device_status(DeviceStatus::Healthy)) } + td class="text-slate-400 text-[12px]" { "2026-05-12 11:00" } + td class="font-mono text-[12px] text-slate-500" { "38s" } + } + tr { + td class="font-mono text-slate-200 whitespace-nowrap" { "telemetry-collector" } + td class="font-mono text-[12px] whitespace-nowrap" { "v0.4.11" } + td { (badges::device_status(DeviceStatus::Failing)) } + td class="text-slate-400 text-[12px]" { "2026-05-04 14:30" } + td class="font-mono text-[12px] text-slate-500" { "1m 08s" } + } + tr { + td class="font-mono text-slate-200 whitespace-nowrap" { "telemetry-collector" } + td class="font-mono text-[12px] whitespace-nowrap" { "v0.4.10" } + td { (badges::device_status(DeviceStatus::Healthy)) } + td class="text-slate-400 text-[12px]" { "2026-04-30 09:15" } + td class="font-mono text-[12px] text-slate-500" { "29s" } + } + } + } + } + } +} + +fn config_tab(device: &DeviceDetail, deployment_version: Option<&str>) -> Markup { + let tags_str = device.tags.join(", "); + let dep_ver = deployment_version.unwrap_or("\u{2014}"); + html! { + div class="card p-5 mt-4" { + div class="section-title mb-2" { "Effective config" } + pre class="font-mono text-[12px] text-slate-300 leading-6 p-4 rounded" style="background:#050608; border:1px solid var(--border)" { + "# generated by harmony-controller @ 2026-05-19 04:12\n" + "device:\n" + " id: " (device.id) "\n" + " region: " (device.region) "\n" + " tags: [" (tags_str) "]\n" + "agent:\n" + " version: " (device.fw) "\n" + " heartbeat_interval: 30s\n" + "deployment:\n" + " name: " @if let Some(dep) = &device.deployment { (dep) } @else { "none" } "\n" + " version: " (dep_ver) "\n" + " tasks:\n" + " - fetch_artifact\n" + " - verify_signature\n" + " - install_deps\n" + " - launch_services\n" + } + } + } +} + +// ── Logs modal (SSE streaming) ───────────────────────────────────────── + pub fn logs_modal(device_id: &str) -> Markup { html! { dialog id="device-logs-modal" - class="m-auto grid grid-rows-[auto_1fr] h-[88vh] w-[min(96vw,82rem)] overflow-hidden rounded-none border-t-2 border-x-0 border-b-0 border-orange-500 bg-[#080a0c] p-0 text-slate-100 shadow-[0_32px_64px_rgba(0,0,0,0.9),0_0_0_1px_rgba(148,163,184,0.06)] backdrop:bg-black/85" + class="m-auto grid grid-rows-[auto_1fr] h-[88vh] w-[min(96vw,82rem)] overflow-hidden rounded-none border-t-2 border-x-0 border-b-0 p-0 text-slate-100 shadow-[0_32px_64px_rgba(0,0,0,0.9),0_0_0_1px_rgba(148,163,184,0.06)] backdrop:bg-black/85" + style="border-color:var(--accent); background:#080a0c" onclick="if (event.target === this) this.close()" onclose="document.getElementById('modal-root').innerHTML = ''" { - div class="flex items-center justify-between border-b border-white/[0.06] bg-[#0c1018] px-5 py-3" { + div class="flex items-center justify-between border-b px-5 py-3" style="background:#0c1018; border-color:var(--border)" { div class="flex items-center gap-3" { span class="relative flex h-1.5 w-1.5 shrink-0" { span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-orange-400 opacity-60" {} - span class="relative inline-flex h-1.5 w-1.5 rounded-full bg-orange-500" {} + span class="relative inline-flex h-1.5 w-1.5 rounded-full" style="background:var(--accent)" {} } code class="text-sm font-medium text-slate-100" { (device_id) } - span class="text-[10px] font-semibold uppercase tracking-[0.15em] text-orange-500/60" { "· logs" } + span class="text-[10px] font-semibold uppercase tracking-[0.15em] text-orange-500/60" { "\u{b7} logs" } } form method="dialog" { button @@ -102,20 +520,20 @@ pub fn logs_modal(device_id: &str) -> Markup { class="flex items-center gap-1.5 text-slate-500 transition-colors hover:text-slate-200" aria-label="Close" { - kbd class="rounded border border-slate-700/80 bg-slate-800/60 px-1.5 py-0.5 font-mono text-[10px] text-slate-400" { "esc" } + kbd class="rounded border bg-slate-800/60 px-1.5 py-0.5 font-mono text-[10px] text-slate-400" style="border-color:var(--border-strong)" { "esc" } span class="text-xs" { "close" } } } } div - class="overflow-y-auto bg-[#050608] py-3 font-mono text-xs leading-6" + class="overflow-y-auto py-3 font-mono text-[11.5px] leading-6 px-5" + style="background:#050608" hx-ext="sse" sse-connect={"/devices/" (device_id) "/logs/stream"} sse-swap="log" - hx-swap="beforeend" - { - div class="px-5 py-1 italic text-slate-700" { "— connecting —" } + hx-swap="beforeend" { + div class="py-px italic text-slate-700" { "\u{2014} connecting \u{2014}" } } } script { @@ -132,3 +550,118 @@ pub fn logs_modal(device_id: &str) -> Markup { } } } + +// ── Row (for blacklist response) ─────────────────────────────────────── + +pub fn row(d: &DeviceDetail) -> Markup { + html! { + tr id={"device-" (d.id)} hx-get={"/device/" (d.id)} hx-target="body" hx-push-url="true" class="cursor-pointer" { + td { + input type="checkbox" class="accent-(--accent) w-3.5 h-3.5 rounded" onclick="event.stopPropagation()" {} + } + td { + span class="font-mono text-slate-100 hover:text-(--accent-fg) hover:underline underline-offset-2 whitespace-nowrap" { + + (&d.id) + } + } + td { (badges::device_status(d.status)) } + td { + @if let Some(dep) = &d.deployment { span class="font-mono text-[12px] text-slate-300 whitespace-nowrap" { (dep) } } + @else { span class="text-slate-700" { "\u{2014}" } } + } + td { span class="text-[12px] text-slate-400 font-mono whitespace-nowrap" { (&d.region) } } + td { span class="font-mono text-[12px] text-slate-500 whitespace-nowrap" { @if let Some(ip) = &d.ip { (ip) } @else { "\u{2014}" } } } + td { span class="font-mono text-[11px] text-slate-500 whitespace-nowrap" { (&d.fw) } } + td { span class="text-[12px] text-slate-500 tabular-nums" { (time_ago(d.minutes_ago)) } } + td class="text-right" { + div class="inline-flex items-center gap-1" { + button + class="text-slate-400 hover:text-slate-100 px-1.5 py-1 rounded hover:bg-white/4" + hx-get={"/devices/" (d.id) "/logs"} + hx-target="#modal-root" + hx-swap="innerHTML" + onclick="event.stopPropagation()" { (PreEscaped(ICON_LIST)) } + button class="text-slate-400 hover:text-slate-100 px-1.5 py-1 rounded hover:bg-white/4" onclick="event.stopPropagation()" { (PreEscaped(ICON_MORE)) } + } + } + } + } +} + +// ── Helpers ──────────────────────────────────────────────────────────── + +fn log_line(severity: &str, ts: &str, device_id: &str, message: &str) -> Markup { + let sev_color = match severity { + "info" => "text-cyan-500", + "warn" => "text-amber-400", + "error" => "text-rose-400", + "debug" => "text-slate-600", + _ => "text-slate-500", + }; + let sev_label = format!("{:5}", severity.to_uppercase()); + html! { + div class={"log-line grid grid-cols-[5rem_3rem_1fr] gap-3 px-0 py-px hover:bg-white/2.5 " (sev_color)} { + span class="tabular-nums text-slate-600" { (ts) } + span class={"font-semibold " (sev_color)} { (sev_label) } + span class="text-slate-300" { + span class="text-cyan-500" { (device_id) } + " " (message) + } + } + } +} + +fn time_ago(minutes: i64) -> String { + if minutes < 1 { + "just now".into() + } else if minutes < 60 { + format!("{}m ago", minutes) + } else if minutes < 60 * 24 { + format!("{}h ago", minutes / 60) + } else { + format!("{}d ago", minutes / (60 * 24)) + } +} + +fn metric_card(label: &str, value: &str, color: &str, seed: u8) -> Markup { + let spark = mini_sparkline(seed, color); + html! { + div { + div class="flex items-baseline gap-2" { + span class="text-[11px] text-slate-500 uppercase tracking-wider" { (label) } + } + div class="text-[22px] font-semibold text-slate-100 mt-1 tabular-nums leading-none" { (value) } + div class="mt-2" { + (PreEscaped(spark)) + } + } + } +} + +fn mini_sparkline(seed: u8, color: &str) -> String { + let w = 210.0; + let h = 36.0; + let values: Vec = (0..24) + .map(|i| { + seed as f64 - 15.0 + + (i as f64 / 3.0).sin() * 10.0 + + ((i as f64 * 7.3 + seed as f64 * 1.7).sin() * 4.0) + }) + .collect(); + crate::frontend::views::dashboard::sparkline_svg(&values, color, w, h, &format!("ms{}", seed)) +} + +fn definition(label: &str, value: &str, mono: bool, copyable: bool) -> Markup { + html! { + div class="flex items-center justify-between py-1.5 text-[12px] border-b last:border-b-0" style="border-color:var(--border)" { + span class="text-slate-500" { (label) } + span class={(if mono { "font-mono whitespace-nowrap" } else { "" }) " text-slate-200 flex items-center gap-1.5"} { + (value) + @if copyable { + button class="text-slate-600 hover:text-slate-300" title="Copy" { (PreEscaped(ICON_COPY)) } + } + } + } + } +} diff --git a/fleet/harmony-fleet-operator/src/frontend/views/mod.rs b/fleet/harmony-fleet-operator/src/frontend/views/mod.rs index 39866099..7cabd8a0 100644 --- a/fleet/harmony-fleet-operator/src/frontend/views/mod.rs +++ b/fleet/harmony-fleet-operator/src/frontend/views/mod.rs @@ -1,4 +1,6 @@ +pub mod alerts; pub mod badges; pub mod dashboard; pub mod deployments; pub mod devices; +pub mod settings; diff --git a/fleet/harmony-fleet-operator/src/frontend/views/settings.rs b/fleet/harmony-fleet-operator/src/frontend/views/settings.rs new file mode 100644 index 00000000..66a702c8 --- /dev/null +++ b/fleet/harmony-fleet-operator/src/frontend/views/settings.rs @@ -0,0 +1,70 @@ +use maud::{Markup, PreEscaped, html}; + +pub fn page() -> Markup { + html! { + div class="p-6 max-w-3xl space-y-4" { + div { + h2 class="text-[15px] font-semibold text-slate-200" { "Notification channels" } + p class="text-[12px] text-slate-500 mt-1" { "Where alerts get delivered when something needs your attention." } + } + (channel_row("Email", "email", "alerts@example.com", true)) + (channel_row("Slack", "slack", "#fleet-alerts", true)) + (channel_row("Discord", "discord", "https://discord.com/api/webhooks/\u{2026}", false)) + (channel_row("SMS", "sms", "+1 555 010 0001", true)) + } + } +} + +fn channel_row(name: &str, key: &str, placeholder: &str, enabled: bool) -> Markup { + let enabled_val = if enabled { "var(--ok)" } else { "rgba(148,163,184,0.2)" }; + let translate = if enabled { "18px" } else { "2px" }; + let display_val = if enabled { placeholder } else { "disabled" }; + + html! { + div class="card p-5" { + div class="flex items-center justify-between" { + div class="flex items-center gap-3" { + span class="inline-flex items-center justify-center w-9 h-9 rounded-md" style="background:var(--bg-elev-2); color:var(--accent-fg)" { + (PreEscaped(channel_icon(key))) + } + div { + div class="text-[14px] text-slate-100 font-medium" { (name) } + div class="text-[11px] text-slate-500" { (display_val) } + } + } + button + class="relative w-9 h-5 rounded-full transition-colors" + style={"background:" (enabled_val)} + hx-post={"/settings/toggle/" (key)} + hx-target="closest .card" + hx-swap="outerHTML" { + span class="absolute top-0.5 w-4 h-4 rounded-full bg-white transition-transform" style={"transform:translateX(" (translate) ")"} {} + } + } + div class={(if enabled { "mt-3 grid grid-cols-1 md:grid-cols-2 gap-3" } else { "mt-3 grid grid-cols-1 md:grid-cols-2 gap-3 max-h-0 opacity-0 overflow-hidden" })} { + div { + label class="text-[11px] text-slate-500 uppercase tracking-wider" { "Destination" } + input class="input mt-1 w-full" style="padding-left:10px" type="text" placeholder=(placeholder) value=(placeholder) {} + } + div { + label class="text-[11px] text-slate-500 uppercase tracking-wider" { "Notify on" } + div class="mt-1 flex gap-1.5" { + span class="chip active" { "critical" } + span class="chip active" { "warning" } + span class="chip" { "info" } + } + } + } + } + } +} + +fn channel_icon(key: &str) -> String { + match key { + "email" => r#""#.to_string(), + "slack" => r#""#.to_string(), + "discord" => r#""#.to_string(), + "sms" => r#""#.to_string(), + _ => r#""#.to_string(), + } +} diff --git a/fleet/harmony-fleet-operator/src/main.rs b/fleet/harmony-fleet-operator/src/main.rs index 5d9d7b45..fed7bf34 100644 --- a/fleet/harmony-fleet-operator/src/main.rs +++ b/fleet/harmony-fleet-operator/src/main.rs @@ -159,6 +159,7 @@ async fn serve_web( live_reload: bool, ) -> Result<()> { use std::sync::Arc; + use std::time::Duration; use frontend::server::{AppState, Config}; use service::{FleetService, mock::MockFleetService}; @@ -173,6 +174,13 @@ async fn serve_web( }; let cookie_key = harmony_zitadel_auth::cookie_key_from_env(); + let config = harmony_zitadel_auth::config_from_env(); + let http_client = reqwest::Client::new(); + + let jwks = harmony_zitadel_auth::JwksCache::new(&config.zitadel_base, http_client.clone()) + .await + .context("initializing JWKS cache")?; + jwks.spawn_background_refresh(Duration::from_secs(900)); frontend::server::run( Config::new(AppState { @@ -180,6 +188,9 @@ async fn serve_web( cookie_key, css_override: css_from, live_reload, + config, + http_client, + jwks, }) .with_addr(addr), ) diff --git a/fleet/harmony-fleet-operator/src/service/mock.rs b/fleet/harmony-fleet-operator/src/service/mock.rs index 27da4675..1e220584 100644 --- a/fleet/harmony-fleet-operator/src/service/mock.rs +++ b/fleet/harmony-fleet-operator/src/service/mock.rs @@ -1,23 +1,17 @@ -//! In-memory `FleetService` with seeded fake data. -//! -//! Used by `serve-web --mock` for local development without a NATS -//! server or a Kubernetes cluster, and by tests that exercise the -//! presentation layer. - -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::Mutex; use async_trait::async_trait; -use chrono::{Duration, Utc}; use super::{ - DashboardSummary, DeploymentStatus, DeploymentSummary, DeviceStatus, DeviceSummary, - FleetService, + Activity, Alert, AlertSeverity, DashboardDetail, DeploymentDetail, DeploymentStatus, + DeviceDetail, DeviceStatus, FleetService, TaskGraph, TaskNode, TaskStatus, }; pub struct MockFleetService { - devices: Mutex>, - deployments: Mutex>, + devices: Mutex>, + deployments: Mutex>, + alerts: Mutex>, } impl Default for MockFleetService { @@ -28,169 +22,639 @@ impl Default for MockFleetService { impl MockFleetService { pub fn with_seeded_data() -> Self { - let now = Utc::now(); - let devices = [ - ( - "pi-001", - DeviceStatus::Healthy, - 30, - Some("kiosk-v3"), - Some("10.0.1.21"), - ), - ( - "pi-002", - DeviceStatus::Healthy, - 45, - Some("kiosk-v3"), - Some("10.0.1.22"), - ), - ( - "pi-003", - DeviceStatus::Healthy, - 12, - Some("kiosk-v3"), - Some("10.0.1.23"), - ), - ( - "pi-004", - DeviceStatus::Pending, - 5, - Some("kiosk-v3"), - Some("10.0.1.24"), - ), - ( - "pi-005", - DeviceStatus::Stale, - 1820, - Some("kiosk-v2"), - Some("10.0.1.25"), - ), - ("pi-006", DeviceStatus::Stale, 2400, Some("kiosk-v2"), None), - ( - "pi-007", - DeviceStatus::Blacklisted, - 600, - None, - Some("10.0.1.27"), - ), - ("pi-008", DeviceStatus::Unknown, 9999, None, None), - ( - "pi-009", - DeviceStatus::Healthy, - 88, - Some("sensor-edge"), - Some("10.0.2.10"), - ), - ( - "pi-010", - DeviceStatus::Pending, - 3, - Some("sensor-edge"), - Some("10.0.2.11"), - ), - ]; - let devices: HashMap = devices - .into_iter() - .map(|(id, status, seconds_ago, deployment, ip)| { - ( - id.to_string(), - DeviceSummary { - id: id.to_string(), - status, - last_seen: now - Duration::seconds(seconds_ago), - deployment: deployment.map(str::to_string), - ip: ip.map(str::to_string), - }, - ) - }) - .collect(); - - let deployments = vec![ - DeploymentSummary { - name: "kiosk-v3".into(), - status: DeploymentStatus::Rolling, - target_devices: 4, - healthy_devices: 3, - }, - DeploymentSummary { - name: "kiosk-v2".into(), - status: DeploymentStatus::Paused, - target_devices: 2, - healthy_devices: 0, - }, - DeploymentSummary { - name: "sensor-edge".into(), - status: DeploymentStatus::Active, - target_devices: 2, - healthy_devices: 1, - }, - DeploymentSummary { - name: "ota-canary".into(), - status: DeploymentStatus::Failing, - target_devices: 1, - healthy_devices: 0, - }, - ]; + let devices = seed_devices(); + let deployments = seed_deployments(); + let alerts = seed_alerts(); Self { devices: Mutex::new(devices), deployments: Mutex::new(deployments), + alerts: Mutex::new(alerts), } } } +// ── Seeded PRNG ──────────────────────────────────────────────────────── + +struct Rng(u32); +impl Iterator for Rng { + type Item = f64; + fn next(&mut self) -> Option { + self.0 = (self.0.wrapping_mul(1103515245).wrapping_add(12345)) & 0x7fffffff; + Some(self.0 as f64 / 0x7fffffff as f64) + } +} + +fn pick<'a, T>(rng: &mut Rng, arr: &'a [T]) -> &'a T { + let i = (rng.next().unwrap() * arr.len() as f64) as usize; + &arr[i.min(arr.len() - 1)] +} + +// ── Device seed ──────────────────────────────────────────────────────── + +fn seed_devices() -> Vec { + let mut rng = Rng(1337); + let hosts = ["edge", "sensor", "gw", "cam", "relay", "meter", "hub"]; + let regions = [ + "eu-paris-1", + "eu-paris-2", + "us-east-1", + "us-west-2", + "apac-tokyo-1", + ]; + let models = [ + "HF-Edge-2", + "HF-Edge-3", + "HF-Sensor-S1", + "HF-Gateway-G2", + "HF-Cam-V1", + ]; + let tags_pool = [ + "prod", "staging", "lab", "eu", "us", "apac", "gpu", "lowpower", "thermal", "pilot", + ]; + + let status_weights: [(DeviceStatus, f64); 5] = [ + (DeviceStatus::Healthy, 0.72), + (DeviceStatus::Pending, 0.10), + (DeviceStatus::Stale, 0.10), + (DeviceStatus::Blacklisted, 0.04), + (DeviceStatus::Unknown, 0.04), + ]; + + let device_deployment: [Option<&str>; 100] = { + let mut arr = [None; 100]; + let dist: [(&str, usize); 7] = [ + ("edge-gateway", 32), + ("sensor-firmware", 41), + ("ingest-pipeline", 8), + ("control-plane", 6), + ("telemetry-collector", 12), + ("gateway-proxy", 4), + ("media-relay", 9), + ]; + let mut idx = 0; + for (name, count) in dist { + for _ in 0..count { + if idx < 100 { + arr[idx] = Some(name); + idx += 1; + } + } + } + arr + }; + + let now = chrono::Utc::now(); + + let mut devices = Vec::with_capacity(100); + for i in 0..100 { + let host = pick(&mut rng, &hosts); + let id = format!("hf-{}-{:03}", host, i + 1); + let status = if i < 4 { + DeviceStatus::Healthy + } else if i == 4 { + DeviceStatus::Failing + } else if i == 5 { + DeviceStatus::Pending + } else { + let r = rng.next().unwrap(); + let mut acc = 0.0; + let mut s = DeviceStatus::Healthy; + for &(st, w) in &status_weights { + acc += w; + if r < acc { + s = st; + break; + } + } + s + }; + + let minutes_ago = match status { + DeviceStatus::Stale => 60 + (rng.next().unwrap() * 4000.0) as i64, + DeviceStatus::Pending => (rng.next().unwrap() * 5.0) as i64, + DeviceStatus::Blacklisted => 600 + (rng.next().unwrap() * 8000.0) as i64, + _ => (rng.next().unwrap() * 12.0) as i64, + }; + let last_seen = now - chrono::Duration::minutes(minutes_ago); + + let ip = format!( + "10.{}.{}.{}", + 20 + (rng.next().unwrap() * 4.0) as u8, + (rng.next().unwrap() * 256.0) as u8, + (rng.next().unwrap() * 256.0) as u8 + ); + + let mut tags = Vec::new(); + let num_tags = 1 + (rng.next().unwrap() * 3.0) as usize; + let mut seen = HashSet::new(); + for _ in 0..num_tags { + let t = pick(&mut rng, &tags_pool).to_string(); + if seen.insert(t.clone()) { + tags.push(t); + } + } + + let model = pick(&mut rng, &models).to_string(); + let region = pick(&mut rng, ®ions).to_string(); + let deployment = device_deployment[i].map(str::to_string); + let fw = format!( + "v{}.{}.{}", + 1 + (rng.next().unwrap() * 3.0) as u8, + (rng.next().unwrap() * 20.0) as u8, + (rng.next().unwrap() * 10.0) as u8 + ); + let uptime_h = if status == DeviceStatus::Stale { + 0 + } else { + (rng.next().unwrap() * 4200.0) as u32 + }; + let cpu = 5 + (rng.next().unwrap() * 70.0) as u8; + let mem = 15 + (rng.next().unwrap() * 70.0) as u8; + + devices.push(DeviceDetail { + id, + status, + last_seen, + minutes_ago, + deployment, + ip: Some(ip), + region, + model, + fw, + tags, + uptime_h, + cpu, + mem, + }); + } + + // Force some control-plane devices to failing + let mut cp_failed = 0; + for d in &mut devices { + if d.deployment.as_deref() == Some("control-plane") && cp_failed < 2 { + d.status = DeviceStatus::Failing; + cp_failed += 1; + } + } + + devices +} + +// ── Deployment seed ──────────────────────────────────────────────────── + +fn seed_deployments() -> Vec { + vec![ + DeploymentDetail { + name: "edge-gateway".into(), + version: "v2.14.1".into(), + status: DeploymentStatus::Active, + target: 32, + healthy: 31, + failing: 0, + pending: 1, + updated_at: "2026-05-19 04:12".into(), + author: "r.tarzalt".into(), + }, + DeploymentDetail { + name: "sensor-firmware".into(), + version: "v0.9.3".into(), + status: DeploymentStatus::Rolling, + target: 41, + healthy: 28, + failing: 1, + pending: 12, + updated_at: "2026-05-19 06:48".into(), + author: "m.lavoie".into(), + }, + DeploymentDetail { + name: "ingest-pipeline".into(), + version: "v1.7.0".into(), + status: DeploymentStatus::Active, + target: 8, + healthy: 8, + failing: 0, + pending: 0, + updated_at: "2026-05-15 11:30".into(), + author: "r.tarzalt".into(), + }, + DeploymentDetail { + name: "control-plane".into(), + version: "v3.2.0".into(), + status: DeploymentStatus::Failing, + target: 6, + healthy: 3, + failing: 2, + pending: 1, + updated_at: "2026-05-19 07:01".into(), + author: "a.singh".into(), + }, + DeploymentDetail { + name: "telemetry-collector".into(), + version: "v0.4.12".into(), + status: DeploymentStatus::Active, + target: 12, + healthy: 12, + failing: 0, + pending: 0, + updated_at: "2026-05-12 09:22".into(), + author: "m.lavoie".into(), + }, + DeploymentDetail { + name: "gateway-proxy".into(), + version: "v1.0.5".into(), + status: DeploymentStatus::Paused, + target: 4, + healthy: 0, + failing: 0, + pending: 4, + updated_at: "2026-05-18 18:14".into(), + author: "r.tarzalt".into(), + }, + DeploymentDetail { + name: "media-relay".into(), + version: "v2.0.0-rc.3".into(), + status: DeploymentStatus::Rolling, + target: 9, + healthy: 5, + failing: 0, + pending: 4, + updated_at: "2026-05-19 06:55".into(), + author: "a.singh".into(), + }, + ] +} + +// ── Alerts seed ──────────────────────────────────────────────────────── + +fn seed_alerts() -> Vec { + vec![ + Alert { + id: "al-1".into(), + severity: AlertSeverity::Critical, + title: "control-plane rollout failing on 2 devices".into(), + deployment: Some("control-plane".into()), + device: Some("hf-gw-018".into()), + at: "2 min ago".into(), + acked: false, + }, + Alert { + id: "al-2".into(), + severity: AlertSeverity::Critical, + title: "hf-sensor-042 unreachable for 14 minutes".into(), + deployment: Some("sensor-firmware".into()), + device: Some("hf-sensor-042".into()), + at: "14 min ago".into(), + acked: false, + }, + Alert { + id: "al-3".into(), + severity: AlertSeverity::Warning, + title: "sensor-firmware rollout stalled at 68%".into(), + deployment: Some("sensor-firmware".into()), + device: None, + at: "22 min ago".into(), + acked: false, + }, + Alert { + id: "al-4".into(), + severity: AlertSeverity::Warning, + title: "hf-cam-011 reporting elevated thermal (78°C)".into(), + deployment: None, + device: Some("hf-cam-011".into()), + at: "1h ago".into(), + acked: false, + }, + Alert { + id: "al-5".into(), + severity: AlertSeverity::Info, + title: "edge-gateway v2.14.1 deployed to 31 devices".into(), + deployment: Some("edge-gateway".into()), + device: None, + at: "3h ago".into(), + acked: true, + }, + ] +} + +// ── Activity feed ───────────────────────────────────────────────────── + +fn activity_feed() -> Vec { + vec![ + Activity { + who: "r.tarzalt".into(), + verb: "started rollout".into(), + target: "sensor-firmware v0.9.3".into(), + at: "07:24".into(), + }, + Activity { + who: "system".into(), + verb: "auto-blacklisted".into(), + target: "hf-sensor-091".into(), + at: "07:18".into(), + }, + Activity { + who: "a.singh".into(), + verb: "paused deployment".into(), + target: "gateway-proxy".into(), + at: "07:02".into(), + }, + Activity { + who: "system".into(), + verb: "detected failure".into(), + target: "hf-gw-018 (control-plane)".into(), + at: "06:51".into(), + }, + Activity { + who: "m.lavoie".into(), + verb: "updated task graph".into(), + target: "telemetry-collector".into(), + at: "06:14".into(), + }, + Activity { + who: "r.tarzalt".into(), + verb: "logged in".into(), + target: String::new(), + at: "06:02".into(), + }, + ] +} + +// ── Trend generation ────────────────────────────────────────────────── + +fn ingest_trend() -> Vec { + let mut rng = Rng(1337); + (0..48) + .map(|i| { + let base = 38.0 + (i as f64 / 4.0).sin() * 8.0 + (rng.next().unwrap() * 6.0); + (base.max(2.0)).round() as u32 + }) + .collect() +} + +fn health_trend() -> Vec { + let mut rng = Rng(1337); + (0..48) + .map(|i| { + let v = 96.0 + + (i as f64 / 6.0).sin() * 1.4 + - if i > 38 && i < 44 { 6.0 } else { 0.0 } + + (rng.next().unwrap() * 0.4); + (v * 10.0).round() / 10.0 + }) + .collect() +} + +// ── Task graph ───────────────────────────────────────────────────────── + +fn task_graph() -> TaskGraph { + let mut positions = HashMap::new(); + positions.insert("t1".into(), (0, 1)); + positions.insert("t2".into(), (1, 1)); + positions.insert("t3".into(), (2, 1)); + positions.insert("t4".into(), (3, 1)); + positions.insert("t5".into(), (4, 1)); + positions.insert("t6".into(), (5, 0)); + positions.insert("t7".into(), (5, 2)); + positions.insert("t8".into(), (6, 1)); + + TaskGraph { + nodes: vec![ + TaskNode { + id: "t1".into(), + label: "fetch artifact".into(), + status: TaskStatus::Done, + duration: "2s".into(), + }, + TaskNode { + id: "t2".into(), + label: "verify signature".into(), + status: TaskStatus::Done, + duration: "0.4s".into(), + }, + TaskNode { + id: "t3".into(), + label: "stop services".into(), + status: TaskStatus::Done, + duration: "1.1s".into(), + }, + TaskNode { + id: "t4".into(), + label: "install deps".into(), + status: TaskStatus::Running, + duration: "12s".into(), + }, + TaskNode { + id: "t5".into(), + label: "mount volumes".into(), + status: TaskStatus::Pending, + duration: "—".into(), + }, + TaskNode { + id: "t6".into(), + label: "launch sensord".into(), + status: TaskStatus::Pending, + duration: "—".into(), + }, + TaskNode { + id: "t7".into(), + label: "launch relayd".into(), + status: TaskStatus::Pending, + duration: "—".into(), + }, + TaskNode { + id: "t8".into(), + label: "health probe".into(), + status: TaskStatus::Pending, + duration: "—".into(), + }, + ], + edges: vec![ + ("t1".into(), "t2".into()), + ("t2".into(), "t3".into()), + ("t3".into(), "t4".into()), + ("t4".into(), "t5".into()), + ("t5".into(), "t6".into()), + ("t5".into(), "t7".into()), + ("t6".into(), "t8".into()), + ("t7".into(), "t8".into()), + ], + positions, + } +} + +// ── FleetService impl ───────────────────────────────────────────────── + #[async_trait] impl FleetService for MockFleetService { - async fn dashboard_summary(&self) -> anyhow::Result { + async fn dashboard_detail(&self) -> anyhow::Result { let devices = self.devices.lock().unwrap(); let deployments = self.deployments.lock().unwrap(); - let mut s = DashboardSummary { + let alerts = self.alerts.lock().unwrap(); + + let mut d = DashboardDetail { devices_total: devices.len() as u32, - deployments_total: deployments.len() as u32, - ..Default::default() + devices_healthy: 0, + devices_pending: 0, + devices_failing: 0, + devices_stale: 0, + devices_blacklisted: 0, + devices_unknown: 0, + deployments_total: deployments.len(), + health_pct: 0, + health_trend: health_trend(), + ingest_rate: *ingest_trend().last().unwrap_or(&0), + ingest_trend: ingest_trend(), + attention_devices: vec![], + activity_feed: activity_feed(), + top_deployments: deployments.clone(), + active_alerts: alerts + .iter() + .filter(|a| !a.acked) + .take(10) + .cloned() + .collect(), + rolling_count: 0, + failing_count: 0, }; - for d in devices.values() { - match d.status { - DeviceStatus::Healthy => s.devices_healthy += 1, - DeviceStatus::Pending => s.devices_pending += 1, - DeviceStatus::Stale => s.devices_stale += 1, - DeviceStatus::Blacklisted => s.devices_blacklisted += 1, - DeviceStatus::Unknown => {} + + for dev in devices.iter() { + match dev.status { + DeviceStatus::Healthy => d.devices_healthy += 1, + DeviceStatus::Pending => d.devices_pending += 1, + DeviceStatus::Stale => d.devices_stale += 1, + DeviceStatus::Failing => d.devices_failing += 1, + DeviceStatus::Blacklisted => d.devices_blacklisted += 1, + DeviceStatus::Unknown => d.devices_unknown += 1, } } - for d in deployments.iter() { - match d.status { - DeploymentStatus::Active | DeploymentStatus::Rolling => s.deployments_active += 1, - DeploymentStatus::Failing => s.deployments_failing += 1, - DeploymentStatus::Paused => {} + d.health_pct = + ((d.devices_healthy as f64 / d.devices_total as f64) * 100.0).round() as u32; + + d.attention_devices = devices + .iter() + .filter(|d| { + d.status == DeviceStatus::Failing + || d.status == DeviceStatus::Stale + || d.status == DeviceStatus::Pending + }) + .take(12) + .cloned() + .collect(); + + for dep in deployments.iter() { + match dep.status { + DeploymentStatus::Rolling => d.rolling_count += 1, + DeploymentStatus::Failing => d.failing_count += 1, + _ => {} } } - Ok(s) + + d.top_deployments.truncate(4); + Ok(d) } - async fn list_devices(&self) -> anyhow::Result> { - let mut out: Vec<_> = self.devices.lock().unwrap().values().cloned().collect(); - out.sort_by(|a, b| a.id.cmp(&b.id)); - Ok(out) + async fn list_devices(&self) -> anyhow::Result> { + Ok(self.devices.lock().unwrap().clone()) } - async fn get_device(&self, id: &str) -> anyhow::Result> { - Ok(self.devices.lock().unwrap().get(id).cloned()) + async fn get_device(&self, id: &str) -> anyhow::Result> { + Ok(self + .devices + .lock() + .unwrap() + .iter() + .find(|d| d.id == id) + .cloned()) } - async fn list_deployments(&self) -> anyhow::Result> { + async fn list_deployments(&self) -> anyhow::Result> { Ok(self.deployments.lock().unwrap().clone()) } - async fn blacklist_device(&self, id: &str) -> anyhow::Result { + async fn get_deployment(&self, name: &str) -> anyhow::Result> { + Ok(self + .deployments + .lock() + .unwrap() + .iter() + .find(|d| d.name == name) + .cloned()) + } + + async fn get_deployment_devices(&self, name: &str) -> anyhow::Result> { + Ok(self + .devices + .lock() + .unwrap() + .iter() + .filter(|d| d.deployment.as_deref() == Some(name)) + .cloned() + .collect()) + } + + async fn blacklist_device(&self, id: &str) -> anyhow::Result { let mut devices = self.devices.lock().unwrap(); let dev = devices - .get_mut(id) + .iter_mut() + .find(|d| d.id == id) .ok_or_else(|| anyhow::anyhow!("device {id} not found"))?; dev.status = DeviceStatus::Blacklisted; dev.deployment = None; Ok(dev.clone()) } + + async fn list_alerts(&self) -> anyhow::Result> { + Ok(self.alerts.lock().unwrap().clone()) + } + + async fn ack_alert(&self, id: &str) -> anyhow::Result { + let mut alerts = self.alerts.lock().unwrap(); + if let Some(a) = alerts.iter_mut().find(|a| a.id == id) { + a.acked = true; + Ok(true) + } else { + Ok(false) + } + } + + async fn get_task_graph(&self, _deployment: &str) -> anyhow::Result { + Ok(task_graph()) + } + + async fn filtered_devices( + &self, + status: Option, + deployment: Option, + region: Option, + search: Option, + ) -> anyhow::Result> { + let devices = self.devices.lock().unwrap(); + let mut out: Vec = devices.iter().cloned().filter(|d| { + if let Some(s) = status { + if d.status != s { return false; } + } + if let Some(ref dep) = deployment { + if d.deployment.as_deref() != Some(dep.as_str()) { return false; } + } + if let Some(ref reg) = region { + if d.region != *reg { return false; } + } + if let Some(ref q) = search { + let q = q.to_lowercase(); + if !d.id.to_lowercase().contains(&q) + && !d.deployment.as_deref().unwrap_or("").to_lowercase().contains(&q) + && !d.ip.as_deref().unwrap_or("").contains(&q) + && !d.tags.iter().any(|t| t.to_lowercase().contains(&q)) + { + return false; + } + } + true + }).collect(); + out.sort_by(|a, b| a.id.cmp(&b.id)); + Ok(out) + } } #[cfg(test)] @@ -198,24 +662,33 @@ mod tests { use super::*; #[tokio::test] - async fn dashboard_summary_counts_by_status() { + async fn dashboard_detail_counts() { let svc = MockFleetService::default(); - let s = svc.dashboard_summary().await.unwrap(); - assert_eq!(s.devices_total, 10); - assert_eq!(s.devices_healthy, 4); - assert_eq!(s.devices_pending, 2); - assert_eq!(s.devices_stale, 2); - assert_eq!(s.devices_blacklisted, 1); + let d = svc.dashboard_detail().await.unwrap(); + assert_eq!(d.devices_total, 100); + assert!(d.devices_healthy > 0); + assert!(d.health_pct > 0); + assert!(!d.activity_feed.is_empty()); } #[tokio::test] async fn blacklist_flips_status() { let svc = MockFleetService::default(); - let before = svc.get_device("pi-001").await.unwrap().unwrap(); - assert_eq!(before.status, DeviceStatus::Healthy); - svc.blacklist_device("pi-001").await.unwrap(); - let after = svc.get_device("pi-001").await.unwrap().unwrap(); + let dev = svc.get_device("hf-edge-001").await.unwrap().unwrap(); + assert_eq!(dev.status, DeviceStatus::Healthy); + svc.blacklist_device("hf-edge-001").await.unwrap(); + let after = svc.get_device("hf-edge-001").await.unwrap().unwrap(); assert_eq!(after.status, DeviceStatus::Blacklisted); assert!(after.deployment.is_none()); } + + #[tokio::test] + async fn filtered_devices_by_status() { + let svc = MockFleetService::default(); + let failing = svc + .filtered_devices(Some(DeviceStatus::Failing), None, None, None) + .await + .unwrap(); + assert!(failing.iter().all(|d| d.status == DeviceStatus::Failing)); + } } diff --git a/fleet/harmony-fleet-operator/src/service/mod.rs b/fleet/harmony-fleet-operator/src/service/mod.rs index 50ebf351..3c927826 100644 --- a/fleet/harmony-fleet-operator/src/service/mod.rs +++ b/fleet/harmony-fleet-operator/src/service/mod.rs @@ -1,17 +1,3 @@ -//! Domain-level fleet query/command surface. -//! -//! Presentation (the `frontend` module) and any future CLI both call -//! into this trait. Implementations: -//! -//! - [`mock::MockFleetService`] — in-memory fake data, for `serve-web --mock` -//! and tests. Reachable without NATS or a Kubernetes cluster. -//! - `real::KubeNatsFleetService` (TODO) — wraps the operator's real -//! data sources (kube client + NATS JetStream KV). - -// The whole module is dead code when neither the web frontend nor any -// future CLI is compiled in — it's intentionally a library surface. -#![allow(dead_code)] - pub mod mock; use async_trait::async_trait; @@ -20,28 +6,51 @@ use serde::Serialize; #[async_trait] pub trait FleetService: Send + Sync + 'static { - async fn dashboard_summary(&self) -> anyhow::Result; - async fn list_devices(&self) -> anyhow::Result>; - async fn get_device(&self, id: &str) -> anyhow::Result>; - async fn list_deployments(&self) -> anyhow::Result>; - async fn blacklist_device(&self, id: &str) -> anyhow::Result; + async fn dashboard_detail(&self) -> anyhow::Result; + async fn list_devices(&self) -> anyhow::Result>; + async fn get_device(&self, id: &str) -> anyhow::Result>; + async fn list_deployments(&self) -> anyhow::Result>; + async fn get_deployment(&self, name: &str) -> anyhow::Result>; + async fn get_deployment_devices(&self, name: &str) -> anyhow::Result>; + async fn blacklist_device(&self, id: &str) -> anyhow::Result; + async fn list_alerts(&self) -> anyhow::Result>; + async fn ack_alert(&self, id: &str) -> anyhow::Result; + async fn get_task_graph(&self, deployment: &str) -> anyhow::Result; + async fn filtered_devices( + &self, + status: Option, + deployment: Option, + region: Option, + search: Option, + ) -> anyhow::Result>; } +// ── Device ───────────────────────────────────────────────────────────── + #[derive(Debug, Clone, Serialize)] -pub struct DeviceSummary { +pub struct DeviceDetail { pub id: String, pub status: DeviceStatus, pub last_seen: DateTime, + pub minutes_ago: i64, pub deployment: Option, pub ip: Option, + pub region: String, + pub model: String, + pub fw: String, + pub tags: Vec, + pub uptime_h: u32, + pub cpu: u8, + pub mem: u8, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)] #[serde(rename_all = "kebab-case")] pub enum DeviceStatus { Healthy, Pending, Stale, + Failing, Blacklisted, Unknown, } @@ -49,24 +58,32 @@ pub enum DeviceStatus { impl DeviceStatus { pub fn label(self) -> &'static str { match self { - DeviceStatus::Healthy => "healthy", - DeviceStatus::Pending => "pending", - DeviceStatus::Stale => "stale", - DeviceStatus::Blacklisted => "blacklisted", - DeviceStatus::Unknown => "unknown", + Self::Healthy => "healthy", + Self::Pending => "pending", + Self::Stale => "stale", + Self::Failing => "failing", + Self::Blacklisted => "blacklisted", + Self::Unknown => "unknown", } } } +// ── Deployment ───────────────────────────────────────────────────────── + #[derive(Debug, Clone, Serialize)] -pub struct DeploymentSummary { +pub struct DeploymentDetail { pub name: String, + pub version: String, pub status: DeploymentStatus, - pub target_devices: u32, - pub healthy_devices: u32, + pub target: u32, + pub healthy: u32, + pub failing: u32, + pub pending: u32, + pub updated_at: String, + pub author: String, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)] #[serde(rename_all = "kebab-case")] pub enum DeploymentStatus { Active, @@ -78,22 +95,101 @@ pub enum DeploymentStatus { impl DeploymentStatus { pub fn label(self) -> &'static str { match self { - DeploymentStatus::Active => "active", - DeploymentStatus::Rolling => "rolling", - DeploymentStatus::Failing => "failing", - DeploymentStatus::Paused => "paused", + Self::Active => "active", + Self::Rolling => "rolling", + Self::Failing => "failing", + Self::Paused => "paused", } } } -#[derive(Debug, Clone, Default, Serialize)] -pub struct DashboardSummary { +// ── Dashboard ────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize)] +pub struct DashboardDetail { pub devices_total: u32, pub devices_healthy: u32, pub devices_pending: u32, + pub devices_failing: u32, pub devices_stale: u32, pub devices_blacklisted: u32, - pub deployments_total: u32, - pub deployments_active: u32, - pub deployments_failing: u32, + pub devices_unknown: u32, + pub deployments_total: usize, + pub health_pct: u32, + pub health_trend: Vec, + pub ingest_rate: u32, + pub ingest_trend: Vec, + pub attention_devices: Vec, + pub activity_feed: Vec, + pub top_deployments: Vec, + pub active_alerts: Vec, + pub rolling_count: usize, + pub failing_count: usize, +} + +// ── Alert ────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize)] +pub struct Alert { + pub id: String, + pub severity: AlertSeverity, + pub title: String, + pub deployment: Option, + pub device: Option, + pub at: String, + pub acked: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum AlertSeverity { + Critical, + Warning, + Info, +} + +impl AlertSeverity { + pub fn label(self) -> &'static str { + match self { + Self::Critical => "critical", + Self::Warning => "warning", + Self::Info => "info", + } + } +} + +// ── Activity ─────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize)] +pub struct Activity { + pub who: String, + pub verb: String, + pub target: String, + pub at: String, +} + +// ── Task Graph ───────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize)] +pub struct TaskGraph { + pub nodes: Vec, + pub edges: Vec<(String, String)>, + pub positions: std::collections::HashMap, +} + +#[derive(Debug, Clone, Serialize)] +pub struct TaskNode { + pub id: String, + pub label: String, + pub status: TaskStatus, + pub duration: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum TaskStatus { + Done, + Running, + Pending, + Failed, } diff --git a/fleet/harmony-fleet-operator/style/input.css b/fleet/harmony-fleet-operator/style/input.css index 41db48a4..3363275b 100644 --- a/fleet/harmony-fleet-operator/style/input.css +++ b/fleet/harmony-fleet-operator/style/input.css @@ -2,3 +2,105 @@ @source "../src"; @source "../style"; + +/* ── CSS Custom Properties (default theme) ─────────────────────────── */ +:root { + --bg: #07090c; + --bg-elev: #0c1018; + --bg-elev-2: #11151f; + --border: rgba(148, 163, 184, 0.08); + --border-strong: rgba(148, 163, 184, 0.16); + --accent: #f97316; /* orange-500 */ + --accent-soft: rgba(249, 115, 22, 0.14); + --accent-fg: #fdba74; + --ok: #34d399; + --ok-soft: rgba(52, 211, 153, 0.13); + --warn: #fbbf24; + --warn-soft: rgba(251, 191, 36, 0.13); + --bad: #fb7185; + --bad-soft: rgba(251, 113, 133, 0.13); + --info: #60a5fa; + --info-soft: rgba(96, 165, 250, 0.13); + --row-h: 38px; +} + +html, body { background: var(--bg); } +body { font-family: 'Inter', sans-serif; color: #e2e8f0; -webkit-font-smoothing: antialiased; } + +::selection { background: var(--accent-soft); color: #fff; } + +/* ── Scrollbar ──────────────────────────────────────────────────────── */ +*::-webkit-scrollbar { width: 10px; height: 10px; } +*::-webkit-scrollbar-track { background: transparent; } +*::-webkit-scrollbar-thumb { background: rgba(148,163,184,0.14); border-radius: 999px; border: 2px solid transparent; background-clip: content-box; } +*::-webkit-scrollbar-thumb:hover { background: rgba(148,163,184,0.28); background-clip: content-box; border: 2px solid transparent; } + +/* ── Animations ─────────────────────────────────────────────────────── */ +@keyframes ping-soft { 0% { transform: scale(1); opacity: .55; } 75%, 100% { transform: scale(2.4); opacity: 0; } } +.pulse-dot::after { content:''; position:absolute; inset:0; border-radius:9999px; background:currentColor; animation: ping-soft 1.8s cubic-bezier(0,0,.2,1) infinite; } +.pulse-dot { position: relative; } + +@keyframes log-in { from { opacity:0; transform: translateY(2px); } to { opacity:1; transform: translateY(0); } } +.log-line { animation: log-in .22s ease-out both; } + +@keyframes draw { from { stroke-dashoffset: 1; } to { stroke-dashoffset: 0; } } +.spark-path { stroke-dasharray: 1; stroke-dashoffset: 1; animation: draw 1.4s ease-out forwards; } + +@keyframes toast-in { from { opacity:0; transform: translateY(8px) scale(.98); } to { opacity:1; transform: translateY(0) scale(1); } } +.toast-in { animation: toast-in .25s cubic-bezier(.2,.7,.3,1) both; } +@keyframes toast-out { to { opacity:0; transform: translateY(-6px) scale(.98); } } +.toast-out { animation: toast-out .22s ease-in both; } + +@keyframes roll-marquee { 0% { transform: translateX(-100%); } 100% { transform: translateX(400%); } } + +/* ── Grid background ────────────────────────────────────────────────── */ +.grid-bg { + background-image: + linear-gradient(rgba(148,163,184,0.04) 1px, transparent 1px), + linear-gradient(90deg, rgba(148,163,184,0.04) 1px, transparent 1px); + background-size: 32px 32px; + background-position: -1px -1px; +} + +/* ── Density ────────────────────────────────────────────────────────── */ +.density-compact { --row-h: 32px; } +.density-comfort { --row-h: 44px; } + +/* ── Buttons ────────────────────────────────────────────────────────── */ +.btn { display:inline-flex; align-items:center; gap:6px; padding:6px 10px; border-radius:7px; font-size:12px; font-weight:500; transition: all .15s; cursor: pointer; border: 1px solid transparent; } +.btn-primary { background: var(--accent); color: #0c0c0c; } +.btn-primary:hover { filter: brightness(1.1); } +.btn-ghost { background: transparent; color: #cbd5e1; border-color: var(--border-strong); } +.btn-ghost:hover { background: rgba(148,163,184,0.06); color:#f1f5f9; } +.btn-danger { background: rgba(244, 63, 94, 0.12); color: #fb7185; border-color: rgba(244,63,94,0.25); } +.btn-danger:hover { background: rgba(244, 63, 94, 0.2); color:#fda4af; } + +/* ── Cards ──────────────────────────────────────────────────────────── */ +.card { background: var(--bg-elev); border: 1px solid var(--border); border-radius: 10px; } +.card-flush { border-radius: 10px; overflow: hidden; } + +/* ── Tables ─────────────────────────────────────────────────────────── */ +.tbl { width: 100%; font-size: 13px; } +.tbl thead th { font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.08em; color: #64748b; font-weight: 600; padding: 10px 14px; text-align: left; background: rgba(148,163,184,0.02); border-bottom: 1px solid var(--border); } +.tbl tbody td { padding: 0 14px; height: var(--row-h); border-bottom: 1px solid var(--border); color: #cbd5e1; } +.tbl tbody tr:hover { background: rgba(148,163,184,0.025); } +.tbl tbody tr.selected { background: var(--accent-soft); } +.tbl tbody tr.selected td { color: #f1f5f9; } + +/* ── Inputs ─────────────────────────────────────────────────────────── */ +.input { background: var(--bg-elev-2); border: 1px solid var(--border-strong); border-radius: 7px; padding: 6px 10px 6px 30px; font-size: 13px; color: #e2e8f0; outline: none; transition: border .15s; } +.input:focus { border-color: var(--accent); } + +/* ── Chips ──────────────────────────────────────────────────────────── */ +.chip { display:inline-flex; align-items:center; gap:6px; padding: 4px 9px; border-radius: 999px; font-size: 11px; font-weight: 500; border: 1px solid var(--border-strong); background: rgba(148,163,184,0.03); color:#cbd5e1; cursor:pointer; } +.chip.active { background: var(--accent-soft); color: var(--accent-fg); border-color: rgba(249,115,22,0.35); } +.chip:hover:not(.active) { color:#f1f5f9; background: rgba(148,163,184,0.07); } + +/* ── Section title ──────────────────────────────────────────────────── */ +.section-title { font-size: 11px; text-transform: uppercase; letter-spacing: 0.1em; font-weight: 600; color: #64748b; } + +/* ── Progress bar ───────────────────────────────────────────────────── */ +.progress-bg { background: rgba(148,163,184,0.1); } + +/* ── Font helpers ───────────────────────────────────────────────────── */ +.id-mono { font-family: 'JetBrains Mono', ui-monospace, monospace; white-space: nowrap; } diff --git a/fleet/harmony-fleet-operator/vendor/app.js b/fleet/harmony-fleet-operator/vendor/app.js new file mode 100644 index 00000000..218b0e6f --- /dev/null +++ b/fleet/harmony-fleet-operator/vendor/app.js @@ -0,0 +1,3 @@ +document.body.addEventListener('htmx:configRequest', (event) => { + event.detail.headers['x-csrf-token'] = '1'; +}); diff --git a/harmony_zitadel_auth/Cargo.toml b/harmony_zitadel_auth/Cargo.toml index 781b1670..7fef2c30 100644 --- a/harmony_zitadel_auth/Cargo.toml +++ b/harmony_zitadel_auth/Cargo.toml @@ -19,7 +19,8 @@ serde.workspace = true serde_json.workspace = true sha2 = "0.10" url.workspace = true -tokio = { workspace = true, features = ["sync", "time"] } +tokio = { workspace = true, features = ["time"] } +arc-swap = "1" time = "0.3" tracing = { workspace = true } diff --git a/harmony_zitadel_auth/src/axum_login_flow.rs b/harmony_zitadel_auth/src/axum_login_flow.rs new file mode 100644 index 00000000..96a9391f --- /dev/null +++ b/harmony_zitadel_auth/src/axum_login_flow.rs @@ -0,0 +1,164 @@ +use anyhow::Result; +use axum::extract::{Query, State}; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Redirect, Response}; +use axum_extra::extract::cookie::{Cookie, PrivateCookieJar, SameSite}; +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; + +use crate::config::ZitadelAuthConfig; +use crate::jwks::JwksCache; +use crate::login::{ + AuthCallbackQuery, RawAuthCallbackQuery, TokenResponse, build_login_attempt, build_logout_url, + exchange_code_for_token, jwt_exp, validate_callback_state, +}; +use crate::session::LoginAttemptCookie; + +pub const LOGIN_ATTEMPT_COOKIE: &str = "harmony_fleet_login_attempt"; +pub const HARMONY_SESSION_COOKIE: &str = "harmony_fleet_session"; + +/// Session cookie holds the raw Zitadel JWT. The `PrivateCookieJar` (AES-GCM) +/// encrypts both the login-attempt cookie (PKCE verifier) and the session cookie +/// (id_token), so the JWT is never exposed in plaintext on the wire. +pub async fn login_handler( + jar: PrivateCookieJar, + State(config): State, +) -> Response { + match build_login_response(jar, &config) { + Ok(r) => r.into_response(), + Err(e) => auth_error_response(e), + } +} + +fn build_login_response( + jar: PrivateCookieJar, + config: &ZitadelAuthConfig, +) -> Result { + let attempt = build_login_attempt(config)?; + let cookie_payload = LoginAttemptCookie::from(&attempt); + let cookie_value = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&cookie_payload)?); + + let mut builder = Cookie::build((LOGIN_ATTEMPT_COOKIE, cookie_value)) + .http_only(true) + .same_site(SameSite::Lax) + .path("/") + .max_age(time::Duration::minutes(10)); + if config.use_secure_cookies() { + builder = builder.secure(true); + } + Ok(( + jar.add(builder.build()), + Redirect::temporary(&attempt.authorize_url), + )) +} + +pub async fn logout_handler( + session_jar: PrivateCookieJar, + State(config): State, +) -> Response { + match build_logout_response(session_jar, &config) { + Ok(r) => r.into_response(), + Err(e) => auth_error_response(e), + } +} + +fn build_logout_response( + session_jar: PrivateCookieJar, + config: &ZitadelAuthConfig, +) -> Result { + // The session cookie value IS the raw JWT (id_token), used as the Zitadel logout hint. + let id_token = session_jar + .get(HARMONY_SESSION_COOKIE) + .map(|c| c.value().to_string()) + .unwrap_or_default(); + let session_jar = session_jar.remove(Cookie::build(HARMONY_SESSION_COOKIE).path("/").build()); + let logout_url = build_logout_url(config, &id_token)?; + Ok((session_jar, Redirect::to(logout_url.as_str()))) +} + +pub async fn callback_handler( + jar: PrivateCookieJar, + session_jar: PrivateCookieJar, + State(config): State, + State(http_client): State, + State(jwks): State, + Query(raw): Query, +) -> Response { + match build_callback_response(jar, session_jar, raw, &config, &http_client, &jwks).await { + Ok(r) => r, + Err(e) => auth_error_response(e), + } +} + +async fn build_callback_response( + jar: PrivateCookieJar, + session_jar: PrivateCookieJar, + raw: RawAuthCallbackQuery, + config: &ZitadelAuthConfig, + http_client: &reqwest::Client, + jwks: &JwksCache, +) -> Result { + match AuthCallbackQuery::try_from(raw)? { + AuthCallbackQuery::Success { code, state } => { + let attempt = read_login_attempt_cookie(&jar)?; + let jar = jar.remove(Cookie::from(LOGIN_ATTEMPT_COOKIE)); + validate_callback_state(&attempt, &state)?; + + let tokens = + exchange_code_for_token(http_client, config, &attempt.pkce_code_verifier, &code) + .await?; + let verified = jwks.verify(&tokens.id_token, config).await?; + if verified.nonce.as_deref() != Some(attempt.nonce.as_str()) { + anyhow::bail!("auth callback nonce mismatch; start again at /login"); + } + + let session_jar = session_jar.add(session_cookie(&tokens, config)); + Ok((jar, session_jar, Redirect::to("/")).into_response()) + } + AuthCallbackQuery::Failure { + error, + error_description, + } => { + anyhow::bail!( + "SSO callback returned an error: {error} {}", + error_description.unwrap_or_default() + ) + } + } +} + +fn session_cookie(tokens: &TokenResponse, config: &ZitadelAuthConfig) -> Cookie<'static> { + let max_age_secs = + jwt_exp(&tokens.id_token).map(|exp| (exp - chrono::Utc::now().timestamp()).max(0)); + + let mut builder = Cookie::build((HARMONY_SESSION_COOKIE, tokens.id_token.clone())) + .http_only(true) + .same_site(SameSite::Lax) + .path("/"); + if config.use_secure_cookies() { + builder = builder.secure(true); + } + if let Some(secs) = max_age_secs { + builder = builder.max_age(time::Duration::seconds(secs)); + } + builder.build() +} + +pub fn read_login_attempt_cookie(jar: &PrivateCookieJar) -> Result { + let cookie = jar + .get(LOGIN_ATTEMPT_COOKIE) + .ok_or_else(|| anyhow::anyhow!("missing login attempt cookie; start again at /login"))?; + let bytes = URL_SAFE_NO_PAD + .decode(cookie.value()) + .map_err(|e| anyhow::anyhow!("invalid login attempt cookie encoding: {e}"))?; + serde_json::from_slice::(&bytes) + .map_err(|e| anyhow::anyhow!("invalid login attempt cookie payload: {e}")) +} + +fn auth_error_response(e: anyhow::Error) -> Response { + ( + StatusCode::BAD_REQUEST, + format!("SSO login failed\nError: {e}\n"), + ) + .into_response() +} diff --git a/harmony_zitadel_auth/src/config.rs b/harmony_zitadel_auth/src/config.rs new file mode 100644 index 00000000..52019426 --- /dev/null +++ b/harmony_zitadel_auth/src/config.rs @@ -0,0 +1,75 @@ +#[derive(Debug, Clone)] +pub struct ZitadelAuthConfig { + pub zitadel_base: String, + pub base_url: String, + pub client_id: String, + pub scope: String, + pub trusted_audiences: Vec, + pub logout_redirect_uri: String, +} + +impl ZitadelAuthConfig { + pub fn issuer_url(&self) -> String { + self.zitadel_base.clone() + } + pub fn authorize_url(&self) -> String { + format!("{}/oauth/v2/authorize", self.zitadel_base) + } + pub fn token_url(&self) -> String { + format!("{}/oauth/v2/token", self.zitadel_base) + } + pub fn logout_url(&self) -> String { + format!("{}/oidc/v1/end_session", self.zitadel_base) + } + pub fn redirect_uri(&self) -> String { + format!("{}/auth/callback", self.base_url) + } + pub fn logout_redirect_uri(&self) -> String { + self.logout_redirect_uri.clone() + } + /// Whether to set the `Secure` flag on cookies. True when `base_url` is HTTPS. + pub fn use_secure_cookies(&self) -> bool { + self.base_url.starts_with("https://") + } +} + +pub const ZITADEL_BASE_ENV: &str = "FLEET_AUTH_ZITADEL_BASE"; +pub const BASE_URL_ENV: &str = "BASE_URL"; +pub const CLIENT_ID_ENV: &str = "FLEET_AUTH_CLIENT_ID"; +pub const SCOPE_ENV: &str = "FLEET_AUTH_SCOPE"; +pub const TRUSTED_AUDIENCES_ENV: &str = "FLEET_AUTH_TRUSTED_AUDIENCES"; +pub const LOGOUT_REDIRECT_URI_ENV: &str = "FLEET_AUTH_LOGOUT_REDIRECT_URI"; +pub const COOKIE_KEY_ENV: &str = "FLEET_OPERATOR_COOKIE_KEY_B64"; + +pub fn config_from_env() -> ZitadelAuthConfig { + ZitadelAuthConfig { + zitadel_base: required_env(ZITADEL_BASE_ENV), + base_url: required_env(BASE_URL_ENV), + client_id: required_env(CLIENT_ID_ENV), + scope: required_env(SCOPE_ENV), + trusted_audiences: required_env(TRUSTED_AUDIENCES_ENV) + .split(',') + .map(str::to_string) + .collect(), + logout_redirect_uri: required_env(LOGOUT_REDIRECT_URI_ENV), + } +} + +#[cfg(feature = "axum")] +pub fn cookie_key_from_env() -> axum_extra::extract::cookie::Key { + use base64::Engine; + use base64::engine::general_purpose::STANDARD; + + let encoded = required_env(COOKIE_KEY_ENV); + let bytes = STANDARD + .decode(encoded.trim()) + .unwrap_or_else(|e| panic!("{COOKIE_KEY_ENV} must be standard base64: {e}")); + if bytes.len() < 64 { + panic!("{COOKIE_KEY_ENV} must decode to at least 64 bytes for private cookies"); + } + axum_extra::extract::cookie::Key::from(&bytes) +} + +fn required_env(name: &str) -> String { + std::env::var(name).unwrap_or_else(|_| panic!("missing required environment variable {name}")) +} diff --git a/harmony_zitadel_auth/src/jwks.rs b/harmony_zitadel_auth/src/jwks.rs new file mode 100644 index 00000000..ed8947cf --- /dev/null +++ b/harmony_zitadel_auth/src/jwks.rs @@ -0,0 +1,210 @@ +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use anyhow::Result; +use serde::Deserialize; + +use crate::config::ZitadelAuthConfig; +use crate::session::VerifiedSession; + +struct JwksCacheInner { + set: jsonwebtoken::jwk::JwkSet, + last_forced_refresh: Option, +} + +/// Cached Zitadel JWKS for per-request JWT verification. +/// +/// Reads are lock-free via `ArcSwap` — only refreshes pay any coordination +/// cost. `Clone` is cheap; the inner state is `Arc`-wrapped. +#[derive(Clone)] +pub struct JwksCache { + inner: Arc>, + jwks_uri: Arc, + http: reqwest::Client, +} + +impl JwksCache { + /// Fetch the JWKS via OIDC discovery and build the cache. + pub async fn new(issuer_url: &str, http: reqwest::Client) -> Result { + let jwks_uri = discover_jwks_uri(issuer_url, &http).await?; + let set = fetch_jwks(&jwks_uri, &http).await?; + tracing::debug!(%jwks_uri, keys = set.keys.len(), "JWKS loaded"); + Ok(Self { + inner: Arc::new(arc_swap::ArcSwap::from_pointee(JwksCacheInner { + set, + last_forced_refresh: None, + })), + jwks_uri: jwks_uri.into(), + http, + }) + } + + /// Spawn a background task that refreshes the JWKS on the given `interval`. + /// + /// On failure the stale keys are kept and a warning is logged — a Zitadel + /// blip must not log everyone out. + pub fn spawn_background_refresh(&self, interval: Duration) { + let cache = self.clone(); + tokio::spawn(async move { + let mut ticker = tokio::time::interval(interval); + ticker.tick().await; // skip the first immediate tick + loop { + ticker.tick().await; + match fetch_jwks(&cache.jwks_uri, &cache.http).await { + Ok(new_set) => { + let last_forced = cache.inner.load().last_forced_refresh; + cache.inner.store(Arc::new(JwksCacheInner { + set: new_set, + last_forced_refresh: last_forced, + })); + tracing::debug!("JWKS background refresh succeeded"); + } + Err(e) => { + tracing::warn!(error = %e, "JWKS background refresh failed; keeping stale keys") + } + } + } + }); + } + + /// Verify a raw JWT string and return the validated session claims. + /// + /// On unknown `kid`, performs one forced JWKS refresh (rate-limited to + /// once per 60 s) before giving up, to handle key rotation gracefully. + pub async fn verify(&self, token: &str, config: &ZitadelAuthConfig) -> Result { + let header = jsonwebtoken::decode_header(token) + .map_err(|e| anyhow::anyhow!("invalid JWT header: {e}"))?; + let kid = header.kid.as_deref().unwrap_or(""); + + // Fast path: lock-free read — ArcSwap guard must not be held across awaits. + { + let inner = self.inner.load(); + if let Some(result) = try_verify_with_set(token, &inner.set, kid, config) { + return result; + } + } + + // Slow path: kid not found — maybe Zitadel rotated keys. + let should_refresh = self + .inner + .load() + .last_forced_refresh + .map(|t| t.elapsed() > Duration::from_secs(60)) + .unwrap_or(true); + + if should_refresh { + match fetch_jwks(&self.jwks_uri, &self.http).await { + Ok(new_set) => { + self.inner.store(Arc::new(JwksCacheInner { + set: new_set, + last_forced_refresh: Some(Instant::now()), + })); + let inner = self.inner.load(); + if let Some(result) = try_verify_with_set(token, &inner.set, kid, config) { + return result; + } + } + Err(e) => tracing::warn!(error = %e, "JWKS forced refresh failed"), + } + } + + anyhow::bail!("unknown JWT signing key (kid={kid:?})") + } +} + +fn try_verify_with_set( + token: &str, + set: &jsonwebtoken::jwk::JwkSet, + kid: &str, + config: &ZitadelAuthConfig, +) -> Option> { + let jwk = if kid.is_empty() { + set.keys.first()? + } else { + set.keys + .iter() + .find(|k| k.common.key_id.as_deref() == Some(kid))? + }; + Some(verify_with_jwk(token, jwk, config)) +} + +fn verify_with_jwk( + token: &str, + jwk: &jsonwebtoken::jwk::Jwk, + config: &ZitadelAuthConfig, +) -> Result { + use jsonwebtoken::jwk::AlgorithmParameters; + use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode}; + + let decoding_key = + DecodingKey::from_jwk(jwk).map_err(|e| anyhow::anyhow!("invalid JWK: {e}"))?; + + // Algorithm is determined from the JWK (server-controlled), not the token header, + // to avoid algorithm-confusion attacks. + let alg = match &jwk.algorithm { + AlgorithmParameters::RSA(_) => Algorithm::RS256, + AlgorithmParameters::EllipticCurve(ec) => { + use jsonwebtoken::jwk::EllipticCurve; + match ec.curve { + EllipticCurve::P256 => Algorithm::ES256, + EllipticCurve::P384 => Algorithm::ES384, + ref c => anyhow::bail!("unsupported elliptic curve: {c:?}"), + } + } + other => anyhow::bail!("unsupported JWK key type: {other:?}"), + }; + + let mut validation = Validation::new(alg); + validation.set_audience(&config.trusted_audiences); + validation.set_issuer(&[&config.zitadel_base]); + + #[derive(Deserialize)] + struct Claims { + sub: String, + exp: i64, + email: Option, + name: Option, + nonce: Option, + } + + let claims = decode::(token, &decoding_key, &validation) + .map_err(|e| anyhow::anyhow!("JWT verification failed: {e}"))? + .claims; + + Ok(VerifiedSession { + subject: claims.sub, + email: claims.email, + name: claims.name, + expires_at: claims.exp, + nonce: claims.nonce, + }) +} + +async fn discover_jwks_uri(issuer_url: &str, http: &reqwest::Client) -> Result { + let url = format!( + "{}/.well-known/openid-configuration", + issuer_url.trim_end_matches('/') + ); + #[derive(Deserialize)] + struct Discovery { + jwks_uri: String, + } + let disc: Discovery = http + .get(&url) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(disc.jwks_uri) +} + +async fn fetch_jwks(jwks_uri: &str, http: &reqwest::Client) -> Result { + Ok(http + .get(jwks_uri) + .send() + .await? + .error_for_status()? + .json::() + .await?) +} diff --git a/harmony_zitadel_auth/src/lib.rs b/harmony_zitadel_auth/src/lib.rs index c80edc73..f5cd0e78 100644 --- a/harmony_zitadel_auth/src/lib.rs +++ b/harmony_zitadel_auth/src/lib.rs @@ -1,648 +1,23 @@ -use std::sync::Arc; - -use anyhow::Result; -use base64::Engine; -use base64::engine::general_purpose::URL_SAFE_NO_PAD; #[cfg(feature = "axum")] -use axum_extra::extract::cookie::Key; -use openidconnect::Nonce; -use openidconnect::core::{CoreClient, CoreIdToken, CoreProviderMetadata}; -use openidconnect::{ClientId, IssuerUrl}; -use rand::random; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use url::Url; - -#[derive(Debug, Clone)] -pub struct ZitadelAuthConfig { - pub zitadel_base: String, - pub base_url: String, - pub client_id: String, - pub scope: String, - pub trusted_audiences: Vec, - pub logout_redirect_uri: String, -} - -/// Outcome of verifying a session cookie JWT on each request. -#[derive(Debug, Clone)] -pub struct VerifiedSession { - pub subject: String, - pub email: Option, - pub name: Option, - pub expires_at: i64, -} - -#[derive(Debug, Clone)] -pub struct ValidatedUser { - pub subject: String, - pub email: Option, - pub name: Option, -} - -#[derive(Debug, Clone)] -pub struct LoginAttempt { - pub authorize_url: String, - pub state: String, - pub pkce_code_verifier: String, -} - -#[derive(Debug, Deserialize)] -pub struct TokenResponse { - pub access_token: String, - pub id_token: String, - pub token_type: String, - pub expires_in: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LoginAttemptCookie { - pub state: String, - pub pkce_code_verifier: String, -} - -#[derive(Debug, Deserialize)] -pub struct RawAuthCallbackQuery { - pub code: Option, - pub state: Option, - pub error: Option, - pub error_description: Option, -} - -#[derive(Debug)] -pub enum AuthCallbackQuery { - Success { - code: String, - state: String, - }, - Failure { - error: String, - error_description: Option, - }, -} - -impl From<&LoginAttempt> for LoginAttemptCookie { - fn from(attempt: &LoginAttempt) -> Self { - Self { - state: attempt.state.clone(), - pkce_code_verifier: attempt.pkce_code_verifier.clone(), - } - } -} - -impl ZitadelAuthConfig { - pub fn issuer_url(&self) -> String { - self.zitadel_base.clone() - } - pub fn authorize_url(&self) -> String { - format!("{}/oauth/v2/authorize", self.zitadel_base) - } - pub fn token_url(&self) -> String { - format!("{}/oauth/v2/token", self.zitadel_base) - } - pub fn logout_url(&self) -> String { - format!("{}/oidc/v1/end_session", self.zitadel_base) - } - pub fn redirect_uri(&self) -> String { - format!("{}/auth/callback", self.base_url) - } - pub fn logout_redirect_uri(&self) -> String { - self.logout_redirect_uri.clone() - } - /// Whether to set the `Secure` flag on cookies. True when `base_url` is HTTPS. - pub fn use_secure_cookies(&self) -> bool { - self.base_url.starts_with("https://") - } -} - -impl TryFrom for AuthCallbackQuery { - type Error = anyhow::Error; - - fn try_from(raw: RawAuthCallbackQuery) -> Result { - match raw { - RawAuthCallbackQuery { - code: Some(code), - state: Some(state), - error: None, - error_description: None, - } => Ok(Self::Success { code, state }), - - RawAuthCallbackQuery { - code: None, - state: _, - error: Some(error), - error_description, - } => Ok(Self::Failure { - error, - error_description, - }), - - _ => Err(anyhow::anyhow!("invalid auth callback query shape")), - } - } -} - -// ── JWKS cache ───────────────────────────────────────────────────────── - -struct JwksCacheInner { - set: jsonwebtoken::jwk::JwkSet, - last_forced_refresh: Option, -} - -/// Cached Zitadel JWKS for per-request JWT verification. -/// -/// `Clone` is cheap — the inner state is `Arc`-wrapped. -#[derive(Clone)] -pub struct JwksCache { - inner: Arc>, - jwks_uri: Arc, - http: reqwest::Client, -} - -impl JwksCache { - /// Fetch the JWKS via OIDC discovery and build the cache. - pub async fn new(issuer_url: &str, http: reqwest::Client) -> Result { - let jwks_uri = discover_jwks_uri(issuer_url, &http).await?; - let set = fetch_jwks(&jwks_uri, &http).await?; - tracing::debug!(%jwks_uri, keys = set.keys.len(), "JWKS loaded"); - Ok(Self { - inner: Arc::new(tokio::sync::RwLock::new(JwksCacheInner { - set, - last_forced_refresh: None, - })), - jwks_uri: jwks_uri.into(), - http, - }) - } - - /// Spawn a background task that refreshes the JWKS on the given `interval`. - /// - /// On failure the stale keys are kept and a warning is logged — a Zitadel - /// blip must not log everyone out. - pub fn spawn_background_refresh(&self, interval: std::time::Duration) { - let cache = self.clone(); - tokio::spawn(async move { - let mut ticker = tokio::time::interval(interval); - ticker.tick().await; // skip the first immediate tick - loop { - ticker.tick().await; - match fetch_jwks(&cache.jwks_uri, &cache.http).await { - Ok(set) => { - cache.inner.write().await.set = set; - tracing::debug!("JWKS background refresh succeeded"); - } - Err(e) => tracing::warn!(error = %e, "JWKS background refresh failed; keeping stale keys"), - } - } - }); - } - - /// Verify a raw JWT string and return the validated session claims. - /// - /// On unknown `kid`, performs one forced JWKS refresh (rate-limited to - /// once per 60 s) before giving up, to handle key rotation gracefully. - pub async fn verify(&self, token: &str, config: &ZitadelAuthConfig) -> Result { - use jsonwebtoken::decode_header; - - let header = decode_header(token).map_err(|e| anyhow::anyhow!("invalid JWT header: {e}"))?; - let kid = header.kid.as_deref().unwrap_or(""); - - // Fast path: verify with cached keys (read lock, no await while held). - { - let inner = self.inner.read().await; - if let Some(result) = try_verify_with_set(token, &inner.set, kid, config) { - return result; - } - } - - // Slow path: kid not found — maybe Zitadel rotated keys. - let should_refresh = { - let inner = self.inner.read().await; - inner - .last_forced_refresh - .map(|t| t.elapsed() > std::time::Duration::from_secs(60)) - .unwrap_or(true) - }; - - if should_refresh { - match fetch_jwks(&self.jwks_uri, &self.http).await { - Ok(new_set) => { - let mut inner = self.inner.write().await; - inner.set = new_set; - inner.last_forced_refresh = Some(std::time::Instant::now()); - if let Some(result) = try_verify_with_set(token, &inner.set, kid, config) { - return result; - } - } - Err(e) => tracing::warn!(error = %e, "JWKS forced refresh failed"), - } - } - - anyhow::bail!("unknown JWT signing key (kid={kid:?})") - } -} - -fn try_verify_with_set( - token: &str, - set: &jsonwebtoken::jwk::JwkSet, - kid: &str, - config: &ZitadelAuthConfig, -) -> Option> { - let jwk = if kid.is_empty() { - set.keys.first()? - } else { - set.keys.iter().find(|k| k.common.key_id.as_deref() == Some(kid))? - }; - Some(verify_with_jwk(token, jwk, config)) -} - -fn verify_with_jwk( - token: &str, - jwk: &jsonwebtoken::jwk::Jwk, - config: &ZitadelAuthConfig, -) -> Result { - use jsonwebtoken::jwk::AlgorithmParameters; - use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode}; - - let decoding_key = DecodingKey::from_jwk(jwk).map_err(|e| anyhow::anyhow!("invalid JWK: {e}"))?; - - // Algorithm is determined from the JWK (server-controlled), not the token header, - // to avoid algorithm-confusion attacks. - let alg = match &jwk.algorithm { - AlgorithmParameters::RSA(_) => Algorithm::RS256, - AlgorithmParameters::EllipticCurve(ec) => { - use jsonwebtoken::jwk::EllipticCurve; - match ec.curve { - EllipticCurve::P256 => Algorithm::ES256, - EllipticCurve::P384 => Algorithm::ES384, - ref c => anyhow::bail!("unsupported elliptic curve: {c:?}"), - } - } - other => anyhow::bail!("unsupported JWK key type: {other:?}"), - }; - - let mut validation = Validation::new(alg); - validation.set_audience(&config.trusted_audiences); - validation.set_issuer(&[&config.zitadel_base]); - - #[derive(Deserialize)] - struct Claims { - sub: String, - exp: i64, - email: Option, - name: Option, - } - - let claims = decode::(token, &decoding_key, &validation) - .map_err(|e| anyhow::anyhow!("JWT verification failed: {e}"))? - .claims; - - Ok(VerifiedSession { - subject: claims.sub, - email: claims.email, - name: claims.name, - expires_at: claims.exp, - }) -} - -async fn discover_jwks_uri(issuer_url: &str, http: &reqwest::Client) -> Result { - let url = format!( - "{}/.well-known/openid-configuration", - issuer_url.trim_end_matches('/') - ); - #[derive(Deserialize)] - struct Discovery { - jwks_uri: String, - } - let disc: Discovery = http - .get(&url) - .send() - .await? - .error_for_status()? - .json() - .await?; - Ok(disc.jwks_uri) -} - -async fn fetch_jwks(jwks_uri: &str, http: &reqwest::Client) -> Result { - let set = http - .get(jwks_uri) - .send() - .await? - .error_for_status()? - .json::() - .await?; - Ok(set) -} - -/// Decode the JWT payload (without verification) to extract `exp` for cookie `Max-Age`. -fn jwt_exp(token: &str) -> Option { - let payload = token.split('.').nth(1)?; - let bytes = URL_SAFE_NO_PAD.decode(payload).ok()?; - let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?; - value.get("exp")?.as_i64() -} - -// ── OIDC login helpers ───────────────────────────────────────────────── - -/// Full OIDC-compliant id_token validation. Used once per login callback; not -/// the per-request hot path (use `JwksCache::verify` for that). -pub async fn validate_id_token( - id_token: &str, - http_client: &reqwest::Client, - config: &ZitadelAuthConfig, -) -> anyhow::Result { - let provider_metadata = - CoreProviderMetadata::discover_async(IssuerUrl::new(config.issuer_url())?, http_client) - .await?; - - let client = CoreClient::from_provider_metadata( - provider_metadata, - ClientId::new(config.client_id.clone()), - None, - ); - - let id_token = CoreIdToken::from_str(id_token)?; - let trusted_audiences = config.trusted_audiences.clone(); - let verifier = client - .id_token_verifier() - .set_other_audience_verifier_fn(move |aud| trusted_audiences.contains(&aud.to_string())); - let claims = id_token.claims(&verifier, |_: Option<&Nonce>| Ok(()))?; - let subject = claims.subject().to_string(); - let email = claims.email().map(|email| email.to_string()); - let name = claims - .name() - .and_then(|localized| localized.get(None)) - .map(|name| name.to_string()); - - Ok(ValidatedUser { - subject, - email, - name, - }) -} - -use std::str::FromStr; - -pub fn build_logout_url(config: &ZitadelAuthConfig, id_token: &str) -> Result { - let mut url = Url::parse(&config.logout_url())?; - url.query_pairs_mut() - .append_pair("post_logout_redirect_uri", &config.logout_redirect_uri()) - .append_pair("id_token_hint", id_token); - Ok(url) -} - -pub fn build_login_attempt(config: &ZitadelAuthConfig) -> Result { - let state = random_url_token(32); - let pkce_code_verifier = random_url_token(32); - let code_challenge = pkce_s256_challenge(&pkce_code_verifier); - - let mut url = Url::parse(&config.authorize_url())?; - url.query_pairs_mut() - .append_pair("client_id", &config.client_id) - .append_pair("redirect_uri", &config.redirect_uri()) - .append_pair("response_type", "code") - .append_pair("scope", &config.scope) - .append_pair("code_challenge", &code_challenge) - .append_pair("code_challenge_method", "S256") - .append_pair("state", &state); - - Ok(LoginAttempt { - authorize_url: url.into(), - state, - pkce_code_verifier, - }) -} - -pub async fn exchange_code_for_token( - client: &reqwest::Client, - config: &ZitadelAuthConfig, - pkce_code_verifier: &str, - code: &str, -) -> anyhow::Result { - let response = client - .post(&config.token_url()) - .form(&[ - ("grant_type", "authorization_code"), - ("code", code), - ("redirect_uri", &config.redirect_uri()), - ("client_id", &config.client_id), - ("code_verifier", pkce_code_verifier), - ]) - .send() - .await?; - - if !response.status().is_success() { - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - anyhow::bail!("failed to exchange code for token: {status} {body}"); - } - - Ok(response.json::().await?) -} - -fn pkce_s256_challenge(code_verifier: &str) -> String { - let digest = Sha256::digest(code_verifier.as_bytes()); - URL_SAFE_NO_PAD.encode(digest) -} - -fn random_url_token(byte_len: usize) -> String { - let mut bytes = vec![0u8; byte_len]; - for chunk in bytes.chunks_mut(32) { - let random_bytes: [u8; 32] = random(); - chunk.copy_from_slice(&random_bytes[..chunk.len()]); - } - URL_SAFE_NO_PAD.encode(bytes) -} - -pub fn validate_callback_state(attempt: &LoginAttemptCookie, returned_state: &str) -> Result<()> { - if attempt.state != returned_state { - anyhow::bail!("auth callback state mismatch; start again at /login"); - } - - Ok(()) -} - -pub const ZITADEL_BASE_ENV: &str = "FLEET_AUTH_ZITADEL_BASE"; -pub const BASE_URL_ENV: &str = "BASE_URL"; -pub const CLIENT_ID_ENV: &str = "FLEET_AUTH_CLIENT_ID"; -pub const SCOPE_ENV: &str = "FLEET_AUTH_SCOPE"; -pub const TRUSTED_AUDIENCES_ENV: &str = "FLEET_AUTH_TRUSTED_AUDIENCES"; -pub const LOGOUT_REDIRECT_URI_ENV: &str = "FLEET_AUTH_LOGOUT_REDIRECT_URI"; -pub const COOKIE_KEY_ENV: &str = "FLEET_OPERATOR_COOKIE_KEY_B64"; - -pub fn config_from_env() -> ZitadelAuthConfig { - ZitadelAuthConfig { - zitadel_base: required_env(ZITADEL_BASE_ENV), - base_url: required_env(BASE_URL_ENV), - client_id: required_env(CLIENT_ID_ENV), - scope: required_env(SCOPE_ENV), - trusted_audiences: required_env(TRUSTED_AUDIENCES_ENV) - .split(',') - .map(str::to_string) - .collect(), - logout_redirect_uri: required_env(LOGOUT_REDIRECT_URI_ENV), - } -} - -fn required_env(name: &str) -> String { - std::env::var(name).unwrap_or_else(|_| panic!("missing required environment variable {name}")) -} +pub mod axum_login_flow; +pub mod config; +pub mod jwks; +pub mod login; +pub mod session; #[cfg(feature = "axum")] -pub fn cookie_key_from_env() -> Key { - use base64::engine::general_purpose::STANDARD; +pub use config::cookie_key_from_env; +pub use config::{ + BASE_URL_ENV, CLIENT_ID_ENV, COOKIE_KEY_ENV, LOGOUT_REDIRECT_URI_ENV, SCOPE_ENV, + TRUSTED_AUDIENCES_ENV, ZITADEL_BASE_ENV, ZitadelAuthConfig, config_from_env, +}; - let encoded = required_env(COOKIE_KEY_ENV); - let bytes = STANDARD - .decode(encoded.trim()) - .unwrap_or_else(|e| panic!("{COOKIE_KEY_ENV} must be standard base64: {e}")); - if bytes.len() < 64 { - panic!("{COOKIE_KEY_ENV} must decode to at least 64 bytes for private cookies"); - } - Key::from(&bytes) -} +pub use jwks::JwksCache; -#[cfg(feature = "axum")] -pub mod axum_login_flow { - use axum::extract::{Query, State}; - use axum::http::StatusCode; - use axum::response::{IntoResponse, Redirect, Response}; - use axum_extra::extract::cookie::{Cookie, CookieJar, PrivateCookieJar, SameSite}; - use base64::engine::general_purpose::URL_SAFE_NO_PAD; +pub use login::{ + AuthCallbackQuery, LoginAttempt, RawAuthCallbackQuery, TokenResponse, ValidatedUser, + build_login_attempt, build_logout_url, exchange_code_for_token, jwt_exp, + validate_callback_state, validate_id_token, +}; - use super::*; - - pub const LOGIN_ATTEMPT_COOKIE: &str = "harmony_fleet_login_attempt"; - pub const HARMONY_SESSION_COOKIE: &str = "harmony_fleet_session"; - - /// Plain cookie jar for session (raw JWT, no encryption needed — JWT is signed by Zitadel). - /// Private cookie jar is kept only for the short-lived login-attempt cookie (PKCE verifier). - pub async fn login_handler( - jar: PrivateCookieJar, - State(config): State, - ) -> Response { - match build_login_response(jar, &config) { - Ok(response) => response.into_response(), - Err(e) => auth_error_response(e), - } - } - - fn build_login_response(jar: PrivateCookieJar, config: &ZitadelAuthConfig) -> Result { - let attempt = build_login_attempt(config)?; - let cookie_payload = LoginAttemptCookie::from(&attempt); - let cookie_value = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&cookie_payload)?); - - let mut builder = Cookie::build((LOGIN_ATTEMPT_COOKIE, cookie_value)) - .http_only(true) - .same_site(SameSite::Lax) - .path("/"); - if config.use_secure_cookies() { - builder = builder.secure(true); - } - let jar = jar.add(builder.build()); - - Ok((jar, Redirect::temporary(&attempt.authorize_url))) - } - - pub async fn logout_handler( - session_jar: CookieJar, - State(config): State, - ) -> Response { - match build_logout_response(session_jar, &config) { - Ok(response) => response.into_response(), - Err(e) => auth_error_response(e), - } - } - - fn build_logout_response(session_jar: CookieJar, config: &ZitadelAuthConfig) -> Result { - // The session cookie value IS the raw JWT (id_token), used as the Zitadel logout hint. - let id_token = session_jar - .get(HARMONY_SESSION_COOKIE) - .map(|c| c.value().to_string()) - .unwrap_or_default(); - - let session_jar = session_jar.remove(Cookie::build(HARMONY_SESSION_COOKIE).path("/").build()); - let logout_url = build_logout_url(config, &id_token)?; - Ok((session_jar, Redirect::to(logout_url.as_str()))) - } - - pub async fn callback_handler( - jar: PrivateCookieJar, - session_jar: CookieJar, - State(config): State, - State(http_client): State, - Query(raw): Query, - ) -> Response { - match build_callback_response(jar, session_jar, raw, &config, &http_client).await { - Ok(response) => response, - Err(e) => auth_error_response(e), - } - } - - async fn build_callback_response( - jar: PrivateCookieJar, - session_jar: CookieJar, - raw: RawAuthCallbackQuery, - config: &ZitadelAuthConfig, - http_client: &reqwest::Client, - ) -> Result { - match AuthCallbackQuery::try_from(raw)? { - AuthCallbackQuery::Success { code, state } => { - let attempt = read_login_attempt_cookie(&jar)?; - let jar = jar.remove(Cookie::from(LOGIN_ATTEMPT_COOKIE)); - validate_callback_state(&attempt, &state)?; - - let tokens = exchange_code_for_token(http_client, config, &attempt.pkce_code_verifier, &code).await?; - // Full OIDC verification at login time (signature + nonce + audience). - validate_id_token(&tokens.id_token, http_client, config).await?; - - let max_age_secs = jwt_exp(&tokens.id_token) - .map(|exp| (exp - chrono::Utc::now().timestamp()).max(0)); - - let mut cookie_builder = Cookie::build((HARMONY_SESSION_COOKIE, tokens.id_token)) - .http_only(true) - .same_site(SameSite::Lax) - .path("/"); - if config.use_secure_cookies() { - cookie_builder = cookie_builder.secure(true); - } - if let Some(secs) = max_age_secs { - cookie_builder = cookie_builder.max_age(time::Duration::seconds(secs)); - } - let session_jar = session_jar.add(cookie_builder.build()); - - Ok((jar, session_jar, Redirect::to("/")).into_response()) - } - AuthCallbackQuery::Failure { error, error_description } => { - anyhow::bail!("SSO callback returned an error: {error} {}", error_description.unwrap_or_default()) - } - } - } - - fn auth_error_response(e: anyhow::Error) -> Response { - (StatusCode::BAD_REQUEST, format!("SSO login failed\nError: {e}\n")).into_response() - } - - pub fn read_login_attempt_cookie(jar: &PrivateCookieJar) -> Result { - let attempt_cookie = jar - .get(LOGIN_ATTEMPT_COOKIE) - .ok_or_else(|| anyhow::anyhow!("missing login attempt cookie; start again at /login"))?; - let bytes = URL_SAFE_NO_PAD - .decode(attempt_cookie.value()) - .map_err(|e| anyhow::anyhow!("invalid login attempt cookie encoding: {e}"))?; - serde_json::from_slice::(&bytes) - .map_err(|e| anyhow::anyhow!("invalid login attempt cookie payload: {e}")) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn pkce_s256_challenge_test() { - let code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; - let challenge = pkce_s256_challenge(code_verifier); - assert_eq!(challenge, "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"); - } -} +pub use session::{LoginAttemptCookie, VerifiedSession}; diff --git a/harmony_zitadel_auth/src/login.rs b/harmony_zitadel_auth/src/login.rs new file mode 100644 index 00000000..6c744fee --- /dev/null +++ b/harmony_zitadel_auth/src/login.rs @@ -0,0 +1,228 @@ +use std::str::FromStr; + +use anyhow::Result; +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use openidconnect::Nonce; +use openidconnect::core::{CoreClient, CoreIdToken, CoreProviderMetadata}; +use openidconnect::{ClientId, IssuerUrl}; +use rand::random; +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use url::Url; + +use crate::config::ZitadelAuthConfig; +use crate::session::LoginAttemptCookie; + +#[derive(Debug, Clone)] +pub struct ValidatedUser { + pub subject: String, + pub email: Option, + pub name: Option, +} + +#[derive(Debug, Clone)] +pub struct LoginAttempt { + pub authorize_url: String, + pub state: String, + pub pkce_code_verifier: String, + pub nonce: String, +} + +#[derive(Debug, Deserialize)] +pub struct TokenResponse { + pub access_token: String, + pub id_token: String, + pub token_type: String, + pub expires_in: Option, +} + +#[derive(Debug, Deserialize)] +pub struct RawAuthCallbackQuery { + pub code: Option, + pub state: Option, + pub error: Option, + pub error_description: Option, +} + +#[derive(Debug)] +pub enum AuthCallbackQuery { + Success { + code: String, + state: String, + }, + Failure { + error: String, + error_description: Option, + }, +} + +impl From<&LoginAttempt> for LoginAttemptCookie { + fn from(attempt: &LoginAttempt) -> Self { + Self { + state: attempt.state.clone(), + pkce_code_verifier: attempt.pkce_code_verifier.clone(), + nonce: attempt.nonce.clone(), + } + } +} + +impl TryFrom for AuthCallbackQuery { + type Error = anyhow::Error; + + fn try_from(raw: RawAuthCallbackQuery) -> Result { + match raw { + RawAuthCallbackQuery { + code: Some(code), + state: Some(state), + error: None, + error_description: None, + } => Ok(Self::Success { code, state }), + RawAuthCallbackQuery { + code: None, + state: _, + error: Some(error), + error_description, + } => Ok(Self::Failure { + error, + error_description, + }), + _ => Err(anyhow::anyhow!("invalid auth callback query shape")), + } + } +} + +/// Full OIDC-compliant id_token validation. Used once per login callback; not +/// the per-request hot path (use `JwksCache::verify` for that). +pub async fn validate_id_token( + id_token: &str, + http_client: &reqwest::Client, + config: &ZitadelAuthConfig, +) -> Result { + let provider_metadata = + CoreProviderMetadata::discover_async(IssuerUrl::new(config.issuer_url())?, http_client) + .await?; + + let client = CoreClient::from_provider_metadata( + provider_metadata, + ClientId::new(config.client_id.clone()), + None, + ); + + let id_token = CoreIdToken::from_str(id_token)?; + let trusted_audiences = config.trusted_audiences.clone(); + let verifier = client + .id_token_verifier() + .set_other_audience_verifier_fn(move |aud| trusted_audiences.contains(&aud.to_string())); + let claims = id_token.claims(&verifier, |_: Option<&Nonce>| Ok(()))?; + + Ok(ValidatedUser { + subject: claims.subject().to_string(), + email: claims.email().map(|e| e.to_string()), + name: claims + .name() + .and_then(|l| l.get(None)) + .map(|n| n.to_string()), + }) +} + +pub fn build_logout_url(config: &ZitadelAuthConfig, id_token: &str) -> Result { + let mut url = Url::parse(&config.logout_url())?; + url.query_pairs_mut() + .append_pair("post_logout_redirect_uri", &config.logout_redirect_uri()) + .append_pair("id_token_hint", id_token); + Ok(url) +} + +pub fn build_login_attempt(config: &ZitadelAuthConfig) -> Result { + let state = random_url_token(32); + let pkce_code_verifier = random_url_token(32); + let nonce = random_url_token(32); + let code_challenge = pkce_s256_challenge(&pkce_code_verifier); + + let mut url = Url::parse(&config.authorize_url())?; + url.query_pairs_mut() + .append_pair("client_id", &config.client_id) + .append_pair("redirect_uri", &config.redirect_uri()) + .append_pair("response_type", "code") + .append_pair("scope", &config.scope) + .append_pair("code_challenge", &code_challenge) + .append_pair("code_challenge_method", "S256") + .append_pair("state", &state) + .append_pair("nonce", &nonce); + + Ok(LoginAttempt { + authorize_url: url.into(), + state, + pkce_code_verifier, + nonce, + }) +} + +pub async fn exchange_code_for_token( + client: &reqwest::Client, + config: &ZitadelAuthConfig, + pkce_code_verifier: &str, + code: &str, +) -> Result { + let response = client + .post(&config.token_url()) + .form(&[ + ("grant_type", "authorization_code"), + ("code", code), + ("redirect_uri", &config.redirect_uri()), + ("client_id", &config.client_id), + ("code_verifier", pkce_code_verifier), + ]) + .send() + .await?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + anyhow::bail!("failed to exchange code for token: {status} {body}"); + } + + Ok(response.json::().await?) +} + +pub fn validate_callback_state(attempt: &LoginAttemptCookie, returned_state: &str) -> Result<()> { + if attempt.state != returned_state { + anyhow::bail!("auth callback state mismatch; start again at /login"); + } + Ok(()) +} + +/// Decode the JWT payload (without verification) to extract `exp` for cookie `Max-Age`. +pub fn jwt_exp(token: &str) -> Option { + let payload = token.split('.').nth(1)?; + let bytes = URL_SAFE_NO_PAD.decode(payload).ok()?; + let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?; + value.get("exp")?.as_i64() +} + +fn pkce_s256_challenge(code_verifier: &str) -> String { + let digest = Sha256::digest(code_verifier.as_bytes()); + URL_SAFE_NO_PAD.encode(digest) +} + +fn random_url_token(byte_len: usize) -> String { + let mut bytes = vec![0u8; byte_len]; + for chunk in bytes.chunks_mut(32) { + let random_bytes: [u8; 32] = random(); + chunk.copy_from_slice(&random_bytes[..chunk.len()]); + } + URL_SAFE_NO_PAD.encode(bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pkce_s256_challenge_test() { + let code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + let challenge = pkce_s256_challenge(code_verifier); + assert_eq!(challenge, "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"); + } +} diff --git a/harmony_zitadel_auth/src/session.rs b/harmony_zitadel_auth/src/session.rs new file mode 100644 index 00000000..8e5f73c3 --- /dev/null +++ b/harmony_zitadel_auth/src/session.rs @@ -0,0 +1,21 @@ +use serde::{Deserialize, Serialize}; + +/// Claims extracted from a verified session cookie JWT on each request. +#[derive(Debug, Clone)] +pub struct VerifiedSession { + pub subject: String, + pub email: Option, + pub name: Option, + pub expires_at: i64, + /// OIDC nonce from the ID token, used to bind callback tokens to login attempts. + pub nonce: Option, +} + +/// PKCE state persisted in the encrypted login-attempt cookie during the +/// Zitadel redirect dance. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LoginAttemptCookie { + pub state: String, + pub pkce_code_verifier: String, + pub nonce: String, +} -- 2.39.5