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
This commit is contained in:
Claude 2026-03-20 13:45:16 +00:00
parent 6ff8c9a267
commit 4454744cba
No known key found for this signature in database
7 changed files with 219 additions and 10 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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).

169
server/src/auth.rs Normal file
View file

@ -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<Mutex<HashSet<String>>>;
pub fn new_sessions() -> Sessions {
Arc::new(Mutex::new(HashSet::new()))
}
// ── Middleware ──────────────────────────────────────────────────────────────
pub async fn auth_middleware(
State(state): State<crate::AppState>,
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<String> {
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<String>,
}
#[derive(Deserialize)]
pub struct LoginForm {
username: String,
password: String,
next: Option<String>,
}
pub async fn login_page(Query(params): Query<NextParam>) -> 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<crate::AppState>,
Form(form): Form<LoginForm>,
) -> 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<crate::AppState>,
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#"<p class="error">Invalid username or password.</p>"#
} else {
""
};
format!(
r#"<!doctype html><html><head><meta charset=utf-8>
<title>HIY Login</title><style>
*{{box-sizing:border-box;margin:0;padding:0}}
body{{font-family:monospace;background:#0f172a;color:#e2e8f0;
display:flex;align-items:center;justify-content:center;min-height:100vh}}
h1{{color:#a78bfa;font-size:1.4rem;margin-bottom:20px;text-align:center}}
.card{{background:#1e293b;border-radius:10px;padding:32px;width:100%;max-width:360px}}
label{{display:block;color:#64748b;font-size:0.78rem;margin-bottom:4px;margin-top:14px}}
input[type=text],input[type=password]{{
background:#0f172a;color:#e2e8f0;border:1px solid #334155;
padding:6px 10px;border-radius:6px;font-family:monospace;font-size:0.9rem;width:100%}}
button[type=submit]{{
background:#4c1d95;border:1px solid #7c3aed;color:#ddd6fe;
padding:8px 0;border-radius:6px;cursor:pointer;font-family:monospace;
font-size:0.9rem;width:100%;margin-top:20px}}
button[type=submit]:hover{{background:#5b21b6}}
.error{{color:#f87171;font-size:0.85rem;margin-top:12px;text-align:center}}
</style></head><body>
<div class="card">
<h1>HostItYourself</h1>
<form method=post action="/login">
<input type=hidden name=next value="{next}">
<label>Username</label>
<input type=text name=username autofocus autocomplete=username>
<label>Password</label>
<input type=password name=password autocomplete=current-password>
<button type=submit>Sign in</button>
{error_html}
</form>
</div></body></html>"#
)
}

View file

@ -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<Mutex<VecDeque<String>>>,
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<String>,
pub admin_pass: Option<String>,
}
#[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::<String>::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());

View file

@ -215,7 +215,7 @@ pub async fn index(State(s): State<AppState>) -> Result<Html<String>, 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#"<nav><h1>&#9749; HostItYourself</h1><span class="muted">{n} app(s)</span></nav>
r#"<nav><h1>&#9749; HostItYourself</h1><span style="display:flex;gap:16px;align-items:center"><span class="muted">{n} app(s)</span><a href="/logout" class="muted" style="font-size:0.82rem">logout</a></span></nav>
<div class="card" style="padding:16px 24px">
<h2 style="margin-bottom:14px">System</h2>
@ -413,6 +413,7 @@ pub async fn app_detail(
<button class="primary" onclick="deploy()">Deploy Now</button>
<button onclick="stopApp()">Stop</button>
<button onclick="restartApp()">Restart</button>
<a href="/logout" class="muted" style="font-size:0.82rem;padding:0 4px">logout</a>
</div>
</nav>
<p class="subtitle">