From fab77775ffaba7ec9a4e242ed94edfd42c928b86 Mon Sep 17 00:00:00 2001 From: Sebas Date: Tue, 23 Jun 2026 21:01:01 +0000 Subject: [PATCH] Stage 3: Admin-UI (maud + htmx) -- Config maskiert anzeigen/aendern, Live-Reload --- crates/dtrack-http/src/admin.rs | 185 ++++++++++++++++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 crates/dtrack-http/src/admin.rs diff --git a/crates/dtrack-http/src/admin.rs b/crates/dtrack-http/src/admin.rs new file mode 100644 index 0000000..7dd28db --- /dev/null +++ b/crates/dtrack-http/src/admin.rs @@ -0,0 +1,185 @@ +use std::path::PathBuf; +use std::sync::Arc; + +use axum::{ + extract::{Request, State}, + http::{ + header::{AUTHORIZATION, WWW_AUTHENTICATE}, + HeaderValue, StatusCode, + }, + middleware::Next, + response::{Html, IntoResponse, Response}, + routing::{get, post}, + Form, Router, +}; +use base64::Engine; +use dtrack_core::{DtrackClient, DtrackConfig}; +use dtrack_tools::SharedClient; +use maud::{html, Markup, PreEscaped, DOCTYPE}; +use serde::Deserialize; +use tokio::sync::RwLock; + +/// Geteilter Zustand der Admin-Routen. +#[derive(Clone)] +pub struct AppState { + pub config: Arc>, + pub client: SharedClient, + pub config_path: Option, +} + +/// Basic-Auth-Credentials fuer /admin. `None` = Admin-UI deaktiviert. +#[derive(Clone)] +pub struct AdminAuth(pub Option>); + +impl AdminAuth { + pub fn from_env() -> Self { + match ( + std::env::var("DTRACK_ADMIN_USER"), + std::env::var("DTRACK_ADMIN_PASSWORD"), + ) { + (Ok(u), Ok(p)) if !u.is_empty() && !p.is_empty() => Self(Some(Arc::new((u, p)))), + _ => { + tracing::warn!( + "DTRACK_ADMIN_USER/PASSWORD nicht gesetzt -- Admin-UI ist deaktiviert." + ); + Self(None) + } + } + } +} + +/// Baut den Admin-Router (eigene Basic-Auth-Schicht + AppState). +pub fn router(state: AppState, auth: AdminAuth) -> Router { + Router::new() + .route("/admin", get(page)) + .route("/admin/config", post(update)) + .route("/admin/reveal", get(reveal)) + .layer(axum::middleware::from_fn_with_state(auth, basic_auth)) + .with_state(state) +} + +async fn basic_auth(State(auth): State, req: Request, next: Next) -> Response { + let Some(creds) = &auth.0 else { + // Admin nicht konfiguriert -> Routen existieren faktisch nicht. + return StatusCode::NOT_FOUND.into_response(); + }; + + let decoded = req + .headers() + .get(AUTHORIZATION) + .and_then(|h| h.to_str().ok()) + .and_then(|h| h.strip_prefix("Basic ")) + .and_then(|b64| base64::engine::general_purpose::STANDARD.decode(b64).ok()) + .and_then(|bytes| String::from_utf8(bytes).ok()); + + let ok = decoded + .as_deref() + .and_then(|s| s.split_once(':')) + .map(|(u, p)| u == creds.0.as_str() && p == creds.1.as_str()) + .unwrap_or(false); + + if ok { + next.run(req).await + } else { + let mut resp = StatusCode::UNAUTHORIZED.into_response(); + resp.headers_mut().insert( + WWW_AUTHENTICATE, + HeaderValue::from_static("Basic realm=\"dtrack-admin\""), + ); + resp + } +} + +const CSS: &str = "body{font-family:system-ui,sans-serif;max-width:680px;margin:2rem auto;padding:0 1rem;color:#222}\ +h1{font-size:1.3rem}label{display:block;margin:1rem 0}\ +input[type=url],input[type=password]{width:100%;padding:.5rem;margin-top:.25rem;box-sizing:border-box}\ +button{padding:.5rem 1rem;cursor:pointer}.ok{color:#176d2c;font-weight:600}\ +#keyfield{font-family:monospace;background:#f3f3f3;padding:.2rem .4rem;border-radius:3px}"; + +fn render(cfg: &DtrackConfig, saved: bool) -> Markup { + html! { + (DOCTYPE) + html lang="de" { + head { + meta charset="utf-8"; + meta name="viewport" content="width=device-width, initial-scale=1"; + title { "dtrack-mcp · Admin" } + script src="https://unpkg.com/htmx.org@2.0.3" {} + style { (PreEscaped(CSS)) } + } + body { + h1 { "Dependency-Track MCP — Konfiguration" } + @if saved { p.ok { "Gespeichert. Client wurde live neu gebaut." } } + form method="post" action="/admin/config" { + label { + "DT-URL" + input type="url" name="url" value=(cfg.url) required; + } + label { + "API-Key (aktuell)" + div { + span #keyfield { (cfg.masked_api_key()) } + " " + button type="button" + hx-get="/admin/reveal" + hx-target="#keyfield" + hx-swap="innerHTML" { "anzeigen" } + } + } + label { + "Neuen API-Key setzen (leer = unveraendert)" + input type="password" name="api_key" autocomplete="off"; + } + label { + input type="checkbox" name="insecure" checked[cfg.insecure]; + " TLS-Pruefung deaktivieren (insecure)" + } + button type="submit" { "Speichern" } + } + } + } + } +} + +async fn page(State(st): State) -> Html { + let cfg = st.config.read().await.clone(); + Html(render(&cfg, false).into_string()) +} + +async fn reveal(State(st): State) -> Html { + let key = st.config.read().await.api_key.clone(); + Html(html! { (key) }.into_string()) +} + +#[derive(Deserialize)] +struct ConfigForm { + url: String, + api_key: String, + insecure: Option, +} + +async fn update(State(st): State, Form(form): Form) -> Response { + let mut cfg = st.config.read().await.clone(); + cfg.url = form.url.trim().to_string(); + let new_key = form.api_key.trim(); + if !new_key.is_empty() { + cfg.api_key = new_key.to_string(); + } + cfg.insecure = form.insecure.is_some(); + + let new_client = match DtrackClient::from_config(&cfg) { + Ok(c) => c, + Err(e) => return (StatusCode::BAD_REQUEST, format!("Config ungueltig: {e}")).into_response(), + }; + + *st.client.write().await = new_client; + *st.config.write().await = cfg.clone(); + + if let Some(path) = &st.config_path { + if let Err(e) = cfg.save(path) { + tracing::error!("Config konnte nicht gespeichert werden: {e}"); + } + } + + Html(render(&cfg, true).into_string()).into_response() +}