Menu
Commit 6a442c68
6a442c68ad0ae8cefff4f913b19ce6df075ab9df by Claude Code
- CHANGELOG.md +5 -0
- Cargo.toml +1 -1
- VERSION +1 -1
- crates/akurai-control/src/listener.rs +112 -12
- crates/akurai-control/src/state.rs +4 -1
- scripts/validate.sh +26 -11
- systemd/akurai-control.service +4 -5
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4d3e652..988afcb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,11 @@ All notable changes to this project are documented here. Format: Keep a Changelo
## [Unreleased]
+## [0.0.6] - 2026-06-28
+
+- Harden VPN control sessions and validation
+
+
## [0.0.5] - 2026-06-27
### Added
diff --git a/Cargo.toml b/Cargo.toml
index d8599f9..3cc1273 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -14,7 +14,7 @@ members = ["crates/akurai-common", "crates/akurai-crypto", "crates/akurai-transp
# official RFC test vectors. The hand-rolled-crypto risk is accepted deliberately.
[workspace.package]
-version = "0.0.5"
+version = "0.0.6"
edition = "2021"
license = "MIT"
repository = "https://github.com/olibuijr/AkurAI-VPN"
diff --git a/VERSION b/VERSION
index bbdeab6..1750564 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.0.5
+0.0.6
diff --git a/crates/akurai-control/src/listener.rs b/crates/akurai-control/src/listener.rs
index 66cb085..b3bb877 100644
--- a/crates/akurai-control/src/listener.rs
+++ b/crates/akurai-control/src/listener.rs
@@ -59,6 +59,12 @@ struct Request {
body: String,
}
+#[derive(Debug, Clone)]
+struct AuthSession {
+ user: AuthUser,
+ csrf_token: String,
+}
+
// ---------------------------------------------------------------------------
// Response
// ---------------------------------------------------------------------------
@@ -313,14 +319,14 @@ fn route(req: &Request, state: &SharedState) -> Response {
let id = p
.trim_start_matches("/api/endpoints/")
.trim_end_matches("/delete");
- delete_endpoint(id, &user, state)
+ delete_endpoint(id, &user, req, state)
}
(Method::Delete, p) if p.starts_with("/api/endpoints/") => {
let Some(user) = require_auth(req, state) else {
return Response::redirect("/login");
};
let id = p.trim_start_matches("/api/endpoints/");
- delete_endpoint(id, &user, state)
+ delete_endpoint(id, &user, req, state)
}
_ => Response::not_found(),
}
@@ -404,6 +410,8 @@ fn handle_callback(req: &Request, state: &SharedState) -> Response {
);
if let Ok(mut st) = state.lock() {
st.sessions.insert(session_token.clone(), user);
+ st.csrf_tokens
+ .insert(session_token.clone(), auth::random_token());
}
let set_cookie = format!(
@@ -417,6 +425,7 @@ fn handle_logout(req: &Request, state: &SharedState) -> Response {
if let Some(token) = auth::parse_session_cookie(cookie) {
if let Ok(mut st) = state.lock() {
st.sessions.remove(&token);
+ st.csrf_tokens.remove(&token);
}
}
}
@@ -438,10 +447,10 @@ fn handle_dashboard(req: &Request, state: &SharedState) -> Response {
.ok()
.map(|s| s.endpoints.clone())
.unwrap_or_default();
- Response::ok_html(render_dashboard(&user, &endpoints))
+ Response::ok_html(render_dashboard(&user.user, &user.csrf_token, &endpoints))
}
-fn handle_list_endpoints(_user: &AuthUser, state: &SharedState) -> Response {
+fn handle_list_endpoints(_user: &AuthSession, state: &SharedState) -> Response {
let json = state
.lock()
.ok()
@@ -456,7 +465,10 @@ fn handle_list_endpoints(_user: &AuthUser, state: &SharedState) -> Response {
Response::ok_json(format!("[{json}]\n"))
}
-fn add_endpoint_json(req: &Request, user: &AuthUser, state: &SharedState) -> Response {
+fn add_endpoint_json(req: &Request, session: &AuthSession, state: &SharedState) -> Response {
+ if !csrf_valid(req, session) {
+ return Response::bad_request("invalid csrf token");
+ }
let name = auth::extract_json_str(&req.body, "name").unwrap_or_default();
let public_key = auth::extract_json_str(&req.body, "public_key").unwrap_or_default();
let endpoint_addr = auth::extract_json_str(&req.body, "endpoint").unwrap_or_default();
@@ -465,10 +477,20 @@ fn add_endpoint_json(req: &Request, user: &AuthUser, state: &SharedState) -> Res
if name.is_empty() || public_key.is_empty() {
return Response::bad_request("name and public_key are required");
}
- do_add_endpoint(name, public_key, endpoint_addr, allowed_ips, user, state)
+ do_add_endpoint(
+ name,
+ public_key,
+ endpoint_addr,
+ allowed_ips,
+ &session.user,
+ state,
+ )
}
-fn add_endpoint_form(req: &Request, user: &AuthUser, state: &SharedState) -> Response {
+fn add_endpoint_form(req: &Request, session: &AuthSession, state: &SharedState) -> Response {
+ if !csrf_valid(req, session) {
+ return Response::error_html("Invalid CSRF token");
+ }
let params = auth::parse_form(&req.body);
let name = params.get("name").cloned().unwrap_or_default();
let public_key = params.get("public_key").cloned().unwrap_or_default();
@@ -486,7 +508,14 @@ fn add_endpoint_form(req: &Request, user: &AuthUser, state: &SharedState) -> Res
if name.is_empty() || public_key.is_empty() {
return Response::error_html("name and public_key are required");
}
- let result = do_add_endpoint(name, public_key, endpoint_addr, allowed_ips, user, state);
+ let result = do_add_endpoint(
+ name,
+ public_key,
+ endpoint_addr,
+ allowed_ips,
+ &session.user,
+ state,
+ );
if result.status.starts_with("2") || result.status.starts_with("3") {
Response::redirect("/dashboard")
} else {
@@ -526,7 +555,15 @@ fn do_add_endpoint(
Response::ok_json(format!("{{\"ok\":true,\"id\":\"{id}\"}}\n"))
}
-fn delete_endpoint(id: &str, _user: &AuthUser, state: &SharedState) -> Response {
+fn delete_endpoint(
+ id: &str,
+ session: &AuthSession,
+ req: &Request,
+ state: &SharedState,
+) -> Response {
+ if !csrf_valid(req, session) {
+ return Response::bad_request("invalid csrf token");
+ }
let mut st = match state.lock() {
Ok(s) => s,
Err(_) => return Response::error_html("Internal state lock error"),
@@ -546,10 +583,24 @@ fn delete_endpoint(id: &str, _user: &AuthUser, state: &SharedState) -> Response
// Auth helper
// ---------------------------------------------------------------------------
-fn require_auth(req: &Request, state: &SharedState) -> Option<AuthUser> {
+fn require_auth(req: &Request, state: &SharedState) -> Option<AuthSession> {
let cookie = req.headers.get("cookie")?;
let token = auth::parse_session_cookie(cookie)?;
- state.lock().ok()?.sessions.get(&token).cloned()
+ let st = state.lock().ok()?;
+ let user = st.sessions.get(&token).cloned()?;
+ let csrf_token = st.csrf_tokens.get(&token).cloned()?;
+ Some(AuthSession { user, csrf_token })
+}
+
+fn csrf_valid(req: &Request, session: &AuthSession) -> bool {
+ csrf_from_request(req).as_deref() == Some(session.csrf_token.as_str())
+}
+
+fn csrf_from_request(req: &Request) -> Option<String> {
+ req.headers
+ .get("x-csrf-token")
+ .cloned()
+ .or_else(|| auth::parse_form(&req.body).get("csrf_token").cloned())
}
// ---------------------------------------------------------------------------
@@ -568,7 +619,11 @@ fn parse_query(query: &str) -> HashMap<String, String> {
// HTML templates
// ---------------------------------------------------------------------------
-fn render_dashboard(user: &AuthUser, endpoints: &[crate::vpn_endpoint::VpnEndpoint]) -> String {
+fn render_dashboard(
+ user: &AuthUser,
+ csrf_token: &str,
+ endpoints: &[crate::vpn_endpoint::VpnEndpoint],
+) -> String {
let table = if endpoints.is_empty() {
r#"<p class="empty">No VPN endpoints registered yet. Add one below.</p>"#.to_string()
} else {
@@ -595,6 +650,7 @@ fn render_dashboard(user: &AuthUser, endpoints: &[crate::vpn_endpoint::VpnEndpoi
<td>{by}</td>\
<td>\
<form method=\"POST\" action=\"/api/endpoints/{id}/delete\" style=\"margin:0\">\
+ <input type=\"hidden\" name=\"csrf_token\" value=\"{csrf}\">\
<button type=\"submit\" class=\"btn btn-danger\">Delete</button>\
</form>\
</td>\
@@ -606,6 +662,7 @@ fn render_dashboard(user: &AuthUser, endpoints: &[crate::vpn_endpoint::VpnEndpoi
ips = html_esc(&ips),
by = html_esc(&e.added_by),
id = html_esc(&e.id),
+ csrf = html_esc(csrf_token),
)
})
.collect();
@@ -666,6 +723,7 @@ input:focus{{outline:none;border-color:#3b82f6}}
<h2>Add Endpoint</h2>
<form method="POST" action="/api/endpoints/add" class="add-form">
+ <input type="hidden" name="csrf_token" value="{csrf_token}">
<div class="field">
<label for="f-name">Name</label>
<input id="f-name" name="name" type="text" placeholder="home-server" required>
@@ -696,6 +754,7 @@ input:focus{{outline:none;border-color:#3b82f6}}
}
},
table = table,
+ csrf_token = html_esc(csrf_token),
)
}
@@ -727,3 +786,44 @@ fn html_esc(s: &str) -> String {
.replace('>', ">")
.replace('"', """)
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn request(headers: &[(&str, &str)], body: &str) -> Request {
+ Request {
+ method: Method::Post,
+ path: "/api/endpoints/add".to_string(),
+ query: String::new(),
+ headers: headers
+ .iter()
+ .map(|(k, v)| (k.to_string(), v.to_string()))
+ .collect(),
+ body: body.to_string(),
+ }
+ }
+
+ #[test]
+ fn csrf_token_can_come_from_header() {
+ let req = request(&[("x-csrf-token", "abc123")], "");
+ assert_eq!(csrf_from_request(&req).as_deref(), Some("abc123"));
+ }
+
+ #[test]
+ fn csrf_token_can_come_from_form_body() {
+ let req = request(&[], "name=node&csrf_token=form-token");
+ assert_eq!(csrf_from_request(&req).as_deref(), Some("form-token"));
+ }
+
+ #[test]
+ fn dashboard_forms_include_csrf_token() {
+ let user = AuthUser {
+ sub: "sub".to_string(),
+ email: "user@example.com".to_string(),
+ name: "User".to_string(),
+ };
+ let html = render_dashboard(&user, "csrf-value", &[]);
+ assert!(html.contains(r#"name="csrf_token" value="csrf-value""#));
+ }
+}
diff --git a/crates/akurai-control/src/state.rs b/crates/akurai-control/src/state.rs
index 4c2ce87..ad2ae38 100644
--- a/crates/akurai-control/src/state.rs
+++ b/crates/akurai-control/src/state.rs
@@ -3,7 +3,7 @@
//! Wrapped in `Arc<Mutex<_>>` so it can be shared across the per-connection
//! threads spawned by the TCP listener.
-use std::collections::HashSet;
+use std::collections::{HashMap, HashSet};
use std::sync::{Arc, Mutex};
use crate::auth::SessionStore;
@@ -17,6 +17,8 @@ pub struct AppState {
pub endpoints: Vec<VpnEndpoint>,
/// Pending OIDC `state` nonces — used for CSRF validation on the callback.
pub pending_states: HashSet<String>,
+ /// Per-session CSRF tokens for state-changing dashboard/API requests.
+ pub csrf_tokens: HashMap<String, String>,
}
impl AppState {
@@ -25,6 +27,7 @@ impl AppState {
sessions: SessionStore::new(),
endpoints: crate::vpn_endpoint::load(),
pending_states: HashSet::new(),
+ csrf_tokens: HashMap::new(),
}
}
}
diff --git a/scripts/validate.sh b/scripts/validate.sh
index a41428d..9a19483 100755
--- a/scripts/validate.sh
+++ b/scripts/validate.sh
@@ -6,27 +6,36 @@ set -euo pipefail
: "${DOMAIN:=vpn.olibuijr.com}"
: "${PORT:=8104}"
: "${APP_NAME:=akurai-vpn-control}"
+: "${SSH_HOST:=akurai-mail}"
RED='\033[0;31m'; GRN='\033[0;32m'; NC='\033[0m'
pass=0; fail=0
-pass_() { printf " ${GRN}PASS${NC} %s\n" "$*"; ((pass++)); }
-fail_() { printf " ${RED}FAIL${NC} %s\n" "$*"; ((fail++)); }
+pass_() { printf " ${GRN}PASS${NC} %s\n" "$*"; pass=$((pass + 1)); }
+fail_() { printf " ${RED}FAIL${NC} %s\n" "$*"; fail=$((fail + 1)); }
+
+remote() {
+ if command -v systemctl >/dev/null 2>&1 && systemctl is-active --quiet "${APP_NAME}.service" 2>/dev/null; then
+ "$@"
+ else
+ ssh -o BatchMode=yes -o ConnectTimeout=5 "$SSH_HOST" "$@"
+ fi
+}
echo "=== Post-deploy validation: ${APP_NAME} ==="
# 1. Systemd (both control plane + sister site)
-systemctl is-active --quiet "${APP_NAME}.service" 2>/dev/null && pass_ "systemd active" || fail_ "${APP_NAME} not active"
-systemctl is-active --quiet akurai-vpn-site.service 2>/dev/null && pass_ "vpn-site systemd active" || fail_ "vpn-site not active"
+remote systemctl is-active --quiet "${APP_NAME}.service" 2>/dev/null && pass_ "systemd active" || fail_ "${APP_NAME} not active"
+remote systemctl is-active --quiet akurai-vpn-site.service 2>/dev/null && pass_ "vpn-site systemd active" || fail_ "vpn-site not active"
# 2. Loopback + public health (uses /health not /api/health)
-curl -fsS --max-time 5 "http://127.0.0.1:${PORT}/health" > /dev/null 2>&1 && pass_ "loopback health" || fail_ "loopback unreachable"
-curl -fsS --max-time 10 "https://${DOMAIN}/health" > /dev/null 2>&1 && pass_ "public health" || fail_ "public unreachable"
+remote curl -fsS --max-time 5 "http://127.0.0.1:${PORT}/api/health" > /dev/null 2>&1 && pass_ "loopback health" || fail_ "loopback unreachable"
+curl -fsS --max-time 10 "https://${DOMAIN}/api/health" > /dev/null 2>&1 && pass_ "public health" || fail_ "public unreachable"
# 3. Status JSON schema
-STATUS=$(curl -fsS --max-time 5 "https://${DOMAIN}/" 2>/dev/null || echo '{}')
+STATUS=$(curl -fsS --max-time 5 "https://${DOMAIN}/api/health" 2>/dev/null || echo '{}')
echo "$STATUS" | python3 -c "
import json,sys
d=json.load(sys.stdin)
-for k in ['status','service','version']:
+for k in ['status','app','version']:
assert k in d, f'missing key: {k}'
" 2>/dev/null && pass_ "status JSON schema valid" || fail_ "status JSON schema invalid"
@@ -57,9 +66,15 @@ DASH_URL="${DASH_CODE#*:}"
LOGOUT_CODE=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 5 "https://${DOMAIN}/auth/logout" 2>/dev/null)
[ "${LOGOUT_CODE:-000}" = "302" ] && pass_ "/auth/logout → 302" || fail_ "/auth/logout → ${LOGOUT_CODE}"
-# 8. Callback without code returns error
-CALLBACK=$(curl -fsS --max-time 5 "https://${DOMAIN}/auth/callback" 2>/dev/null || echo "")
-echo "$CALLBACK" | grep -qi 'error\|missing\|Invalid' && pass_ "/auth/callback (no code) shows error" || fail_ "/auth/callback should show error"
+# 8. Callback without code returns an intentional error page
+CALLBACK_TMP=$(mktemp)
+CALLBACK_CODE=$(curl -sS -o "$CALLBACK_TMP" -w '%{http_code}' --max-time 5 "https://${DOMAIN}/auth/callback" 2>/dev/null || echo "000")
+if [ "$CALLBACK_CODE" = "500" ] && grep -qi 'error\|missing\|Invalid' "$CALLBACK_TMP"; then
+ pass_ "/auth/callback (no code) shows error"
+else
+ fail_ "/auth/callback → ${CALLBACK_CODE} (expected 500 error page)"
+fi
+rm -f "$CALLBACK_TMP"
# 9. API endpoints require auth
ENDPOINTS_CODE=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 5 "https://${DOMAIN}/api/endpoints" 2>/dev/null)
diff --git a/systemd/akurai-control.service b/systemd/akurai-control.service
index 0d8ec20..2da3389 100644
--- a/systemd/akurai-control.service
+++ b/systemd/akurai-control.service
@@ -16,11 +16,10 @@ ConfigurationDirectory=akurai-vpn
Environment=AKURAI_VPN_STATE=/var/lib/akurai-vpn
Environment=AKURAI_VPN_CONFIG=/etc/akurai-vpn
-# OIDC — see passvault entry "AkurAI-VPN OIDC client"
-Environment=OIDC_CLIENT_ID=ed2c64e6db2797da007adddf80a13d04
-Environment=OIDC_CLIENT_SECRET=abc9e4e4f0d4c071c1c5d2cb3708b31b19026801a11f77fec49a2301e6dd0a6965e8e96e4b0c35cb
-Environment=OIDC_REDIRECT_URI=https://vpn.olibuijr.com/auth/callback
-Environment=OIDC_ISSUER_URL=https://auth.olibuijr.com
+# OIDC — provision /etc/akurai-vpn/oidc.env from akurai-passvault.
+# Required keys: OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_REDIRECT_URI,
+# and OIDC_ISSUER_URL.
+EnvironmentFile=/etc/akurai-vpn/oidc.env
# Hardening (the control plane needs no special privileges)
DynamicUser=yes