AkurAI Build
Menu

AkurAI-VPNCommit 6a442c68

Commit 6a442c68

6a442c68ad0ae8cefff4f913b19ce6df075ab9df by Claude Code

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('>', "&gt;")
         .replace('"', "&quot;")
 }
+
+#[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