From 4454744cbafc02937b7c2c5ddaeadb1a0e8cf57b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Mar 2026 13:45:16 +0000 Subject: [PATCH] Add session-based auth to dashboard and API - New HIY_ADMIN_USER / HIY_ADMIN_PASS env vars control access - Login page at /login with redirect-after-login support - Cookie-based sessions (HttpOnly, SameSite=Strict); cleared on restart - Auth middleware applied to all routes except /webhook/:app_id (HMAC) and /login - Auth is skipped when credentials are not configured (dev mode, warns at startup) - Logout link in both dashboard nav bars - Caddy admin port 2019 no longer published to the host in docker-compose https://claude.ai/code/session_01FKCW3FDjNFj6jve4niMFXH --- .env.example | 5 ++ infra/.env.example | 4 + infra/docker-compose.yml | 3 +- proxy/Caddyfile | 3 + server/src/auth.rs | 169 +++++++++++++++++++++++++++++++++++++++ server/src/main.rs | 42 ++++++++-- server/src/routes/ui.rs | 3 +- 7 files changed, 219 insertions(+), 10 deletions(-) create mode 100644 server/src/auth.rs diff --git a/.env.example b/.env.example index 4258e32..e4ed822 100644 --- a/.env.example +++ b/.env.example @@ -19,5 +19,10 @@ DOMAIN_SUFFIX=localhost # Email for Let's Encrypt registration (production only; ignored for localhost). ACME_EMAIL=you@yourdomain.com +# Dashboard login credentials. +# Set both to enable authentication; leave unset only for local dev. +HIY_ADMIN_USER=admin +HIY_ADMIN_PASS=changeme + # Rust log filter. RUST_LOG=hiy_server=debug,tower_http=info diff --git a/infra/.env.example b/infra/.env.example index 9f654ef..d09e8d9 100644 --- a/infra/.env.example +++ b/infra/.env.example @@ -3,3 +3,7 @@ DOMAIN_SUFFIX=yourdomain.com # Optional: email for Let's Encrypt expiry notices. # If you want this, uncomment the `email` line in proxy/Caddyfile instead. + +# Dashboard login credentials (required in production). +HIY_ADMIN_USER=admin +HIY_ADMIN_PASS=changeme diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index 22d9715..379f33d 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -54,7 +54,8 @@ services: ports: - "80:80" - "443:443" - - "2019:2019" # admin API + # Port 2019 (Caddy admin API) is intentionally NOT published to the host. + # It is only reachable within the hiy-net Docker network (http://caddy:2019). env_file: - path: ../.env required: false diff --git a/proxy/Caddyfile b/proxy/Caddyfile index 430f3b3..08a70f7 100644 --- a/proxy/Caddyfile +++ b/proxy/Caddyfile @@ -13,6 +13,9 @@ { # Admin API — used by hiy-server to add/remove app routes dynamically. + # Listens on all interfaces so the server container can reach it via Docker + # networking (http://caddy:2019). Port 2019 must NOT be port-forwarded on + # the host or router — see docker-compose.yml. admin 0.0.0.0:2019 # Email for Let's Encrypt expiry notices (set ACME_EMAIL in .env). diff --git a/server/src/auth.rs b/server/src/auth.rs new file mode 100644 index 0000000..9d4521d --- /dev/null +++ b/server/src/auth.rs @@ -0,0 +1,169 @@ +use axum::{ + extract::{Form, Query, Request, State}, + http::header, + middleware::Next, + response::{Html, IntoResponse, Redirect, Response}, +}; +use serde::Deserialize; +use std::collections::HashSet; +use std::sync::Arc; +use tokio::sync::Mutex; + +pub type Sessions = Arc>>; + +pub fn new_sessions() -> Sessions { + Arc::new(Mutex::new(HashSet::new())) +} + +// ── Middleware ────────────────────────────────────────────────────────────── + +pub async fn auth_middleware( + State(state): State, + request: Request, + next: Next, +) -> Response { + // Auth disabled when credentials are not configured. + if state.admin_user.is_none() { + return next.run(request).await; + } + + let token = session_cookie(request.headers()); + let authenticated = match token { + Some(t) => state.sessions.lock().await.contains(&t), + None => false, + }; + + if authenticated { + next.run(request).await + } else { + let path = request + .uri() + .path_and_query() + .map(|p| p.as_str()) + .unwrap_or("/"); + Redirect::to(&format!("/login?next={}", safe_path(path))).into_response() + } +} + +fn session_cookie(headers: &axum::http::HeaderMap) -> Option { + headers + .get(header::COOKIE) + .and_then(|v| v.to_str().ok()) + .and_then(|cookies| { + cookies.split(';').find_map(|c| { + let c = c.trim(); + c.strip_prefix("session=").map(|s| s.to_string()) + }) + }) +} + +/// Reject open-redirect: only allow relative paths. +fn safe_path(next: &str) -> String { + if next.starts_with('/') && !next.starts_with("//") { + next.to_string() + } else { + "/".to_string() + } +} + +// ── Login / Logout ────────────────────────────────────────────────────────── + +#[derive(Deserialize)] +pub struct NextParam { + next: Option, +} + +#[derive(Deserialize)] +pub struct LoginForm { + username: String, + password: String, + next: Option, +} + +pub async fn login_page(Query(params): Query) -> impl IntoResponse { + let next = params.next.map(|s| safe_path(&s)).unwrap_or_else(|| "/".into()); + Html(login_html(&next, false)) +} + +pub async fn handle_login( + State(state): State, + Form(form): Form, +) -> Response { + let next = form.next.as_deref().map(safe_path).unwrap_or_else(|| "/".into()); + + let valid = state.admin_user.as_deref() == Some(form.username.as_str()) + && state.admin_pass.as_deref() == Some(form.password.as_str()); + + if !valid { + return Html(login_html(&next, true)).into_response(); + } + + let token = uuid::Uuid::new_v4().to_string(); + state.sessions.lock().await.insert(token.clone()); + + let cookie = format!("session={}; HttpOnly; SameSite=Strict; Path=/", token); + let mut response = Redirect::to(&next).into_response(); + response + .headers_mut() + .insert(header::SET_COOKIE, cookie.parse().unwrap()); + response +} + +pub async fn handle_logout( + State(state): State, + headers: axum::http::HeaderMap, +) -> Response { + if let Some(token) = session_cookie(&headers) { + state.sessions.lock().await.remove(&token); + } + let mut response = Redirect::to("/login").into_response(); + response.headers_mut().insert( + header::SET_COOKIE, + "session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0" + .parse() + .unwrap(), + ); + response +} + +// ── Login page HTML ───────────────────────────────────────────────────────── + +fn login_html(next: &str, error: bool) -> String { + let error_html = if error { + r#"

Invalid username or password.

"# + } else { + "" + }; + format!( + r#" +HIY — Login +
+

HostItYourself

+
+ + + + + + + {error_html} +
+
"# + ) +} diff --git a/server/src/main.rs b/server/src/main.rs index 37b78ef..bec6e02 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -1,4 +1,5 @@ use axum::{ + middleware, routing::{delete, get, post}, Router, }; @@ -7,6 +8,7 @@ use tokio::sync::Mutex; use std::collections::VecDeque; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; +mod auth; mod builder; mod db; mod models; @@ -20,6 +22,11 @@ pub struct AppState { /// Queue of deploy IDs waiting to be processed. pub build_queue: Arc>>, pub data_dir: String, + /// In-memory session tokens (cleared on restart). + pub sessions: auth::Sessions, + /// None means auth is disabled (neither env var set). + pub admin_user: Option, + pub admin_pass: Option, } #[tokio::main] @@ -42,12 +49,25 @@ async fn main() -> anyhow::Result<()> { let db = db::connect(&data_dir).await?; db::migrate(&db).await?; + let admin_user = std::env::var("HIY_ADMIN_USER").ok().filter(|s| !s.is_empty()); + let admin_pass = std::env::var("HIY_ADMIN_PASS").ok().filter(|s| !s.is_empty()); + + if admin_user.is_none() || admin_pass.is_none() { + tracing::warn!( + "HIY_ADMIN_USER / HIY_ADMIN_PASS not set — dashboard is unprotected! \ + Set both in .env to enable authentication." + ); + } + let build_queue = Arc::new(Mutex::new(VecDeque::::new())); let state = AppState { db, build_queue, data_dir, + sessions: auth::new_sessions(), + admin_user, + admin_pass, }; // Single background worker — sequential builds to avoid saturating the Pi. @@ -56,29 +76,35 @@ async fn main() -> anyhow::Result<()> { builder::build_worker(worker_state).await; }); - let app = Router::new() - // ── Dashboard UI ────────────────────────────────────────── + // ── Protected routes (require login) ────────────────────────────────────── + let protected = Router::new() .route("/", get(routes::ui::index)) .route("/apps/:id", get(routes::ui::app_detail)) - // ── Status API (container + deploy combined) ─────────────── .route("/api/status", get(routes::ui::status_json)) - // ── Apps API ────────────────────────────────────────────── .route("/api/apps", get(routes::apps::list).post(routes::apps::create)) .route("/api/apps/:id", get(routes::apps::get_one) .put(routes::apps::update) .delete(routes::apps::delete)) .route("/api/apps/:id/stop", post(routes::apps::stop)) .route("/api/apps/:id/restart", post(routes::apps::restart)) - // ── Deploys API ─────────────────────────────────────────── .route("/api/apps/:id/deploy", post(routes::deploys::trigger)) .route("/api/apps/:id/deploys", get(routes::deploys::list)) .route("/api/deploys/:id", get(routes::deploys::get_one)) .route("/api/deploys/:id/logs", get(routes::deploys::logs_sse)) - // ── Env vars API ────────────────────────────────────────── .route("/api/apps/:id/env", get(routes::envvars::list).post(routes::envvars::set)) .route("/api/apps/:id/env/:key", delete(routes::envvars::remove)) - // ── GitHub Webhook ──────────────────────────────────────── - .route("/webhook/:app_id", post(routes::webhooks::github)) + .route_layer(middleware::from_fn_with_state(state.clone(), auth::auth_middleware)); + + // ── Public routes (no auth) ─────────────────────────────────────────────── + let public = Router::new() + .route("/login", get(auth::login_page).post(auth::handle_login)) + .route("/logout", get(auth::handle_logout)) + // GitHub webhooks use HMAC-SHA256 — no session needed. + .route("/webhook/:app_id", post(routes::webhooks::github)); + + let app = Router::new() + .merge(protected) + .merge(public) .with_state(state); let addr = std::env::var("HIY_ADDR").unwrap_or_else(|_| "0.0.0.0:3000".into()); diff --git a/server/src/routes/ui.rs b/server/src/routes/ui.rs index 0e4e9ef..6d7f79a 100644 --- a/server/src/routes/ui.rs +++ b/server/src/routes/ui.rs @@ -215,7 +215,7 @@ pub async fn index(State(s): State) -> Result, StatusCode let ram_pct = if stats.ram_total_mb > 0 { stats.ram_used_mb * 100 / stats.ram_total_mb } else { 0 }; let body = format!( - r#" + r#"

System

@@ -413,6 +413,7 @@ pub async fn app_detail( + logout