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:
parent
6ff8c9a267
commit
4454744cba
7 changed files with 219 additions and 10 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
169
server/src/auth.rs
Normal 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>"#
|
||||
)
|
||||
}
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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>☕ HostItYourself</h1><span class="muted">{n} app(s)</span></nav>
|
||||
r#"<nav><h1>☕ 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">
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue