Add Phase 6 client portal and enrollment flow
This commit is contained in:
parent
46fb878f8d
commit
4893eb8312
41
Setup.md
41
Setup.md
|
|
@ -304,6 +304,47 @@ Session note (2026-04-25, Phase 2.5 ownership and concurrency):
|
||||||
- race-free behavior is currently defined for session CRUD and counter increments inside one process per VM
|
- race-free behavior is currently defined for session CRUD and counter increments inside one process per VM
|
||||||
- persistence, distributed session authority, and multi-proxy/multi-server coordination are not implemented yet
|
- persistence, distributed session authority, and multi-proxy/multi-server coordination are not implemented yet
|
||||||
|
|
||||||
|
Session note (2026-04-25, Phase 6 client portal prototype):
|
||||||
|
- Added browser-facing client process:
|
||||||
|
- `/home/user/chromecard/k_client_portal.py`
|
||||||
|
- Current Phase 6 prototype shape:
|
||||||
|
- portal runs in `k_client` on `http://127.0.0.1:8766`
|
||||||
|
- portal keeps local enrolled username state in `k_client`
|
||||||
|
- portal calls `k_proxy` over the validated TLS forward `https://127.0.0.1:9771`
|
||||||
|
- Current local enrollment model:
|
||||||
|
- enrollment is a client-local username selection stored by the portal
|
||||||
|
- no dedicated server-side enrollment API exists yet
|
||||||
|
- Verified portal API flow in `k_client`:
|
||||||
|
- `GET /health` returns `ok=true`
|
||||||
|
- `POST /api/enroll` with `alice` succeeds
|
||||||
|
- `POST /api/login` succeeds and returns a proxy session token
|
||||||
|
- `POST /api/status` succeeds
|
||||||
|
- `POST /api/resource/counter` succeeds twice with upstream values `3` and `4`
|
||||||
|
- `POST /api/logout` succeeds
|
||||||
|
- Current implication:
|
||||||
|
- `k_client` now has a concrete client-side process instead of only runbook curls
|
||||||
|
- browser-facing flow is now available through the local portal
|
||||||
|
- next hardening step is to replace client-local enrollment with the intended enrollment contract and decide whether browser traffic should eventually talk to `k_proxy` directly or continue through a local client portal
|
||||||
|
|
||||||
|
Session note (2026-04-25, Phase 6 enrollment contract):
|
||||||
|
- Added proxy-side enrollment API and storage:
|
||||||
|
- `POST /enroll/register`
|
||||||
|
- `GET /enroll/status?username=<name>`
|
||||||
|
- persisted prototype store at `/home/user/chromecard/k_proxy_enrollments.json` in `k_proxy`
|
||||||
|
- Current enrollment authority is now `k_proxy`, not the `k_client` portal.
|
||||||
|
- Current portal behavior:
|
||||||
|
- portal enrollment calls `k_proxy` over TLS
|
||||||
|
- portal keeps only a preferred local username for convenience
|
||||||
|
- portal login now depends on proxy-side enrollment existing
|
||||||
|
- Verified behavior:
|
||||||
|
- direct proxy login for unenrolled `bob` returns `{"ok": false, "error": "user not enrolled", ...}`
|
||||||
|
- portal enrollment of `alice` succeeds and persists in proxy-side enrollment storage
|
||||||
|
- proxy enrollment status for `alice` returns `ok=true`
|
||||||
|
- portal login and protected counter access still succeed after enrollment
|
||||||
|
- Practical meaning:
|
||||||
|
- Phase 6 now has a real `k_client -> k_proxy` enrollment request path
|
||||||
|
- the remaining gap is not basic routing; it is deciding the final enrollment semantics and whether the browser should stay behind a local portal or talk to `k_proxy` directly
|
||||||
|
|
||||||
Session note (2026-04-25, in-VM forwarding test):
|
Session note (2026-04-25, in-VM forwarding test):
|
||||||
- Tested the intended in-VM forwarding path with `qvm-connect-tcp` instead of host-side `qrexec-client-vm`.
|
- Tested the intended in-VM forwarding path with `qvm-connect-tcp` instead of host-side `qrexec-client-vm`.
|
||||||
- Forwarders start and bind locally:
|
- Forwarders start and bind locally:
|
||||||
|
|
|
||||||
23
Workplan.md
23
Workplan.md
|
|
@ -262,6 +262,29 @@ Exit criteria:
|
||||||
Exit criteria:
|
Exit criteria:
|
||||||
- Enrollment and login both function end-to-end via `k_client -> k_proxy -> k_server`.
|
- Enrollment and login both function end-to-end via `k_client -> k_proxy -> k_server`.
|
||||||
|
|
||||||
|
Status (2026-04-25):
|
||||||
|
- Added first `k_client` implementation at `/home/user/chromecard/k_client_portal.py`.
|
||||||
|
- Current prototype flow:
|
||||||
|
- browser talks to local portal on `k_client`
|
||||||
|
- portal stores only a preferred username locally
|
||||||
|
- portal calls `k_proxy` over `https://127.0.0.1:9771`
|
||||||
|
- `k_proxy` continues to authenticate with the card and forward to `k_server`
|
||||||
|
- Verified end-to-end through the portal:
|
||||||
|
- enroll `alice`
|
||||||
|
- login succeeds
|
||||||
|
- session status succeeds
|
||||||
|
- protected counter succeeds repeatedly with session reuse
|
||||||
|
- logout succeeds
|
||||||
|
- Enrollment contract progress:
|
||||||
|
- `k_proxy` now exposes prototype enrollment endpoints
|
||||||
|
- proxy-side enrollment storage exists and is checked before login is allowed
|
||||||
|
- `k_client` portal enrollment now routes to `k_proxy` over TLS instead of remaining client-local only
|
||||||
|
- Phase 6 is materially further along for the current prototype shape:
|
||||||
|
- client-side process exists
|
||||||
|
- login/resource flow is integrated
|
||||||
|
- enrollment now has a real client->proxy path
|
||||||
|
- final enrollment semantics and UI shape are still provisional
|
||||||
|
|
||||||
## Phase 6.5: Concurrency and Multi-Client Test Setup
|
## Phase 6.5: Concurrency and Multi-Client Test Setup
|
||||||
|
|
||||||
1. Single-VM concurrency tests.
|
1. Single-VM concurrency tests.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,483 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Minimal browser-facing client portal for Phase 6 bring-up.
|
||||||
|
|
||||||
|
This runs in k_client, keeps a local preferred username, and talks to k_proxy
|
||||||
|
over the localhost-forwarded TLS endpoint.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import ssl
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from urllib.error import HTTPError, URLError
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
|
||||||
|
HTML = """<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>ChromeCard Client Portal</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #f4efe4;
|
||||||
|
--panel: #fffaf1;
|
||||||
|
--ink: #1a1712;
|
||||||
|
--muted: #6d6456;
|
||||||
|
--line: #d7c9b2;
|
||||||
|
--accent: #1c6b57;
|
||||||
|
--accent-2: #ae6a2b;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "Iowan Old Style", "Palatino Linotype", serif;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(174,106,43,0.16), transparent 34%),
|
||||||
|
linear-gradient(180deg, #f9f3e8 0%, var(--bg) 100%);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
main {
|
||||||
|
max-width: 880px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 32px 20px 56px;
|
||||||
|
}
|
||||||
|
.hero {
|
||||||
|
padding: 22px 24px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: linear-gradient(135deg, rgba(255,250,241,0.98), rgba(243,232,214,0.95));
|
||||||
|
box-shadow: 0 18px 40px rgba(55, 41, 19, 0.08);
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: clamp(2rem, 4vw, 3.4rem);
|
||||||
|
line-height: 0.95;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
}
|
||||||
|
.subtitle {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
max-width: 62ch;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||||
|
gap: 18px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
.card h2 {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: #fff;
|
||||||
|
font: inherit;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
border: 0;
|
||||||
|
padding: 10px 14px;
|
||||||
|
font: inherit;
|
||||||
|
color: #fff;
|
||||||
|
background: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
button.secondary { background: var(--accent-2); }
|
||||||
|
.status {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 14px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
pre {
|
||||||
|
margin: 18px 0 0;
|
||||||
|
min-height: 280px;
|
||||||
|
padding: 16px;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: #16130f;
|
||||||
|
color: #efe7da;
|
||||||
|
font-family: "SFMono-Regular", Consolas, monospace;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<section class="hero">
|
||||||
|
<h1>ChromeCard Client Portal</h1>
|
||||||
|
<p class="subtitle">
|
||||||
|
Prototype browser flow for k_client. Enrollment state lives here. Login,
|
||||||
|
session status, counter access, and logout go through the current k_proxy TLS API.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Enrollment</h2>
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input id="username" placeholder="alice" autocomplete="off">
|
||||||
|
<div class="actions">
|
||||||
|
<button id="enrollBtn">Enroll User</button>
|
||||||
|
</div>
|
||||||
|
<div class="status">
|
||||||
|
<div>Enrolled user: <strong id="enrolledUser">none</strong></div>
|
||||||
|
<div>Session active: <strong id="sessionActive">no</strong></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Session Flow</h2>
|
||||||
|
<div class="actions">
|
||||||
|
<button id="loginBtn">Login</button>
|
||||||
|
<button id="statusBtn" class="secondary">Status</button>
|
||||||
|
<button id="counterBtn">Counter</button>
|
||||||
|
<button id="logoutBtn" class="secondary">Logout</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<pre id="log"></pre>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const logNode = document.getElementById("log");
|
||||||
|
const enrolledUserNode = document.getElementById("enrolledUser");
|
||||||
|
const sessionActiveNode = document.getElementById("sessionActive");
|
||||||
|
const usernameNode = document.getElementById("username");
|
||||||
|
|
||||||
|
function log(message, payload) {
|
||||||
|
const stamp = new Date().toLocaleTimeString();
|
||||||
|
let line = `[${stamp}] ${message}`;
|
||||||
|
if (payload !== undefined) {
|
||||||
|
line += "\\n" + JSON.stringify(payload, null, 2);
|
||||||
|
}
|
||||||
|
logNode.textContent = line + "\\n\\n" + logNode.textContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(path, payload) {
|
||||||
|
const resp = await fetch(path, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {"Content-Type": "application/json"},
|
||||||
|
body: JSON.stringify(payload || {})
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!resp.ok) {
|
||||||
|
throw new Error(JSON.stringify(data));
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshState() {
|
||||||
|
const resp = await fetch("/api/client/state");
|
||||||
|
const data = await resp.json();
|
||||||
|
enrolledUserNode.textContent = data.enrolled_username || "none";
|
||||||
|
sessionActiveNode.textContent = data.session_active ? "yes" : "no";
|
||||||
|
if (data.enrolled_username && !usernameNode.value) {
|
||||||
|
usernameNode.value = data.enrolled_username;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("enrollBtn").addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
const data = await api("/api/enroll", {username: usernameNode.value});
|
||||||
|
log("Enrollment updated", data);
|
||||||
|
await refreshState();
|
||||||
|
} catch (err) {
|
||||||
|
log("Enrollment failed", {error: err.message});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("loginBtn").addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
const data = await api("/api/login");
|
||||||
|
log("Login ok", data);
|
||||||
|
await refreshState();
|
||||||
|
} catch (err) {
|
||||||
|
log("Login failed", {error: err.message});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("statusBtn").addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
const data = await api("/api/status");
|
||||||
|
log("Session status", data);
|
||||||
|
await refreshState();
|
||||||
|
} catch (err) {
|
||||||
|
log("Status failed", {error: err.message});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("counterBtn").addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
const data = await api("/api/resource/counter");
|
||||||
|
log("Counter response", data);
|
||||||
|
await refreshState();
|
||||||
|
} catch (err) {
|
||||||
|
log("Counter failed", {error: err.message});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("logoutBtn").addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
const data = await api("/api/logout");
|
||||||
|
log("Logout response", data);
|
||||||
|
await refreshState();
|
||||||
|
} catch (err) {
|
||||||
|
log("Logout failed", {error: err.message});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
refreshState().catch((err) => log("Initial state load failed", {error: err.message}));
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EnrollmentRecord:
|
||||||
|
username: str
|
||||||
|
|
||||||
|
|
||||||
|
class ClientState:
|
||||||
|
def __init__(self, proxy_base_url: str, proxy_ca_file: str | None, enroll_db: Path):
|
||||||
|
self.proxy_base_url = proxy_base_url.rstrip("/")
|
||||||
|
self.proxy_ca_file = proxy_ca_file
|
||||||
|
self.enroll_db = enroll_db
|
||||||
|
self.lock = threading.Lock()
|
||||||
|
self.preferred_enrollment: EnrollmentRecord | None = None
|
||||||
|
self.session_token: str | None = None
|
||||||
|
self.session_expires_at: int | None = None
|
||||||
|
self._load_preferred_enrollment()
|
||||||
|
|
||||||
|
def _ssl_context(self):
|
||||||
|
if self.proxy_base_url.startswith("https://"):
|
||||||
|
return ssl.create_default_context(cafile=self.proxy_ca_file)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _proxy_json(self, method: str, path: str, payload: dict[str, Any] | None = None) -> tuple[int, dict[str, Any]]:
|
||||||
|
req = Request(f"{self.proxy_base_url}{path}", method=method)
|
||||||
|
req.add_header("Content-Type", "application/json")
|
||||||
|
token = self.get_session_token()
|
||||||
|
if token:
|
||||||
|
req.add_header("Authorization", f"Bearer {token}")
|
||||||
|
body = json.dumps(payload or {}).encode("utf-8")
|
||||||
|
try:
|
||||||
|
with urlopen(req, data=body, timeout=10, context=self._ssl_context()) as resp:
|
||||||
|
return resp.status, json.loads(resp.read().decode("utf-8"))
|
||||||
|
except HTTPError as exc:
|
||||||
|
try:
|
||||||
|
return exc.code, json.loads(exc.read().decode("utf-8"))
|
||||||
|
except Exception:
|
||||||
|
return exc.code, {"ok": False, "error": f"proxy http error {exc.code}"}
|
||||||
|
except URLError as exc:
|
||||||
|
return 502, {"ok": False, "error": f"proxy unavailable: {exc.reason}"}
|
||||||
|
except Exception as exc:
|
||||||
|
return 502, {"ok": False, "error": f"proxy call failed: {exc}"}
|
||||||
|
|
||||||
|
def _load_preferred_enrollment(self) -> None:
|
||||||
|
if not self.enroll_db.exists():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
data = json.loads(self.enroll_db.read_text())
|
||||||
|
username = str(data.get("username", "")).strip()
|
||||||
|
if username:
|
||||||
|
self.preferred_enrollment = EnrollmentRecord(username=username)
|
||||||
|
except Exception:
|
||||||
|
self.preferred_enrollment = None
|
||||||
|
|
||||||
|
def _save_preferred_enrollment_locked(self) -> None:
|
||||||
|
self.enroll_db.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
payload = {"username": self.preferred_enrollment.username if self.preferred_enrollment else None}
|
||||||
|
self.enroll_db.write_text(json.dumps(payload, indent=2) + "\n")
|
||||||
|
|
||||||
|
def enroll(self, username: str) -> dict[str, Any]:
|
||||||
|
username = username.strip()
|
||||||
|
if not username:
|
||||||
|
return {"ok": False, "error": "username required"}
|
||||||
|
status, data = self._proxy_json("POST", "/enroll/register", {"username": username})
|
||||||
|
if status != 200:
|
||||||
|
return data
|
||||||
|
with self.lock:
|
||||||
|
self.preferred_enrollment = EnrollmentRecord(username=username)
|
||||||
|
self._save_preferred_enrollment_locked()
|
||||||
|
self.session_token = None
|
||||||
|
self.session_expires_at = None
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"enrolled_username": username,
|
||||||
|
"proxy_enrollment": data,
|
||||||
|
}
|
||||||
|
|
||||||
|
def snapshot(self) -> dict[str, Any]:
|
||||||
|
with self.lock:
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"enrolled_username": self.preferred_enrollment.username if self.preferred_enrollment else None,
|
||||||
|
"session_active": bool(self.session_token),
|
||||||
|
"session_expires_at": self.session_expires_at,
|
||||||
|
"proxy_base_url": self.proxy_base_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_session_token(self) -> str | None:
|
||||||
|
with self.lock:
|
||||||
|
return self.session_token
|
||||||
|
|
||||||
|
def login(self) -> tuple[int, dict[str, Any]]:
|
||||||
|
with self.lock:
|
||||||
|
if not self.preferred_enrollment:
|
||||||
|
return 400, {"ok": False, "error": "no enrolled user"}
|
||||||
|
username = self.preferred_enrollment.username
|
||||||
|
|
||||||
|
status, data = self._proxy_json("POST", "/session/login", {"username": username})
|
||||||
|
if status == 200 and data.get("session_token"):
|
||||||
|
with self.lock:
|
||||||
|
self.session_token = data["session_token"]
|
||||||
|
self.session_expires_at = int(data.get("expires_at", 0)) or None
|
||||||
|
return status, data
|
||||||
|
|
||||||
|
def status(self) -> tuple[int, dict[str, Any]]:
|
||||||
|
return self._proxy_json("POST", "/session/status")
|
||||||
|
|
||||||
|
def counter(self) -> tuple[int, dict[str, Any]]:
|
||||||
|
return self._proxy_json("POST", "/resource/counter")
|
||||||
|
|
||||||
|
def logout(self) -> tuple[int, dict[str, Any]]:
|
||||||
|
status, data = self._proxy_json("POST", "/session/logout")
|
||||||
|
if status == 200:
|
||||||
|
with self.lock:
|
||||||
|
self.session_token = None
|
||||||
|
self.session_expires_at = None
|
||||||
|
return status, data
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
state: ClientState
|
||||||
|
|
||||||
|
def _json(self, status: int, payload: dict[str, Any]) -> None:
|
||||||
|
body = json.dumps(payload).encode("utf-8")
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def _html(self, body: str) -> None:
|
||||||
|
data = body.encode("utf-8")
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(data)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(data)
|
||||||
|
|
||||||
|
def _read_json(self) -> dict[str, Any]:
|
||||||
|
length = int(self.headers.get("Content-Length", "0"))
|
||||||
|
raw = self.rfile.read(length)
|
||||||
|
if not raw:
|
||||||
|
return {}
|
||||||
|
return json.loads(raw.decode("utf-8"))
|
||||||
|
|
||||||
|
def do_GET(self) -> None: # noqa: N802
|
||||||
|
path = urlparse(self.path).path
|
||||||
|
if path == "/":
|
||||||
|
self._html(HTML)
|
||||||
|
return
|
||||||
|
if path == "/health":
|
||||||
|
self._json(200, {"ok": True, "service": "k_client_portal", "time": int(time.time())})
|
||||||
|
return
|
||||||
|
if path == "/api/client/state":
|
||||||
|
self._json(200, self.state.snapshot())
|
||||||
|
return
|
||||||
|
self.send_error(404)
|
||||||
|
|
||||||
|
def do_POST(self) -> None: # noqa: N802
|
||||||
|
path = urlparse(self.path).path
|
||||||
|
if path == "/api/enroll":
|
||||||
|
try:
|
||||||
|
data = self._read_json()
|
||||||
|
except Exception:
|
||||||
|
self._json(400, {"ok": False, "error": "invalid json"})
|
||||||
|
return
|
||||||
|
result = self.state.enroll(str(data.get("username", "")))
|
||||||
|
self._json(200 if result.get("ok") else 400, result)
|
||||||
|
return
|
||||||
|
if path == "/api/login":
|
||||||
|
status, data = self.state.login()
|
||||||
|
self._json(status, data)
|
||||||
|
return
|
||||||
|
if path == "/api/status":
|
||||||
|
status, data = self.state.status()
|
||||||
|
self._json(status, data)
|
||||||
|
return
|
||||||
|
if path == "/api/resource/counter":
|
||||||
|
status, data = self.state.counter()
|
||||||
|
self._json(status, data)
|
||||||
|
return
|
||||||
|
if path == "/api/logout":
|
||||||
|
status, data = self.state.logout()
|
||||||
|
self._json(status, data)
|
||||||
|
return
|
||||||
|
self.send_error(404)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description="Run browser-facing client portal in k_client")
|
||||||
|
parser.add_argument("--host", default="127.0.0.1")
|
||||||
|
parser.add_argument("--port", type=int, default=8766)
|
||||||
|
parser.add_argument("--proxy-base-url", default="https://127.0.0.1:9771")
|
||||||
|
parser.add_argument("--proxy-ca-file", help="CA certificate used to verify k_proxy HTTPS certificate")
|
||||||
|
parser.add_argument("--enroll-db", default="/home/user/chromecard/k_client_enrollment.json")
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
if args.proxy_base_url.startswith("https://") and not args.proxy_ca_file:
|
||||||
|
raise SystemExit("--proxy-ca-file is required when --proxy-base-url uses https")
|
||||||
|
|
||||||
|
Handler.state = ClientState(
|
||||||
|
proxy_base_url=args.proxy_base_url,
|
||||||
|
proxy_ca_file=args.proxy_ca_file,
|
||||||
|
enroll_db=Path(args.enroll_db),
|
||||||
|
)
|
||||||
|
server = ThreadingHTTPServer((args.host, args.port), Handler)
|
||||||
|
print(f"k_client_portal listening on http://{args.host}:{args.port}")
|
||||||
|
server.serve_forever()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
117
k_proxy_app.py
117
k_proxy_app.py
|
|
@ -23,6 +23,7 @@ import threading
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.error import HTTPError, URLError
|
from urllib.error import HTTPError, URLError
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
@ -35,6 +36,12 @@ class Session:
|
||||||
expires_at: float
|
expires_at: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Enrollment:
|
||||||
|
username: str
|
||||||
|
enrolled_at: int
|
||||||
|
|
||||||
|
|
||||||
class ProxyState:
|
class ProxyState:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
|
@ -43,14 +50,18 @@ class ProxyState:
|
||||||
server_base_url: str,
|
server_base_url: str,
|
||||||
server_ca_file: str | None,
|
server_ca_file: str | None,
|
||||||
proxy_token: str,
|
proxy_token: str,
|
||||||
|
enrollment_db: Path,
|
||||||
):
|
):
|
||||||
self.session_ttl_s = session_ttl_s
|
self.session_ttl_s = session_ttl_s
|
||||||
self.auth_command = auth_command
|
self.auth_command = auth_command
|
||||||
self.server_base_url = server_base_url.rstrip("/")
|
self.server_base_url = server_base_url.rstrip("/")
|
||||||
self.server_ca_file = server_ca_file
|
self.server_ca_file = server_ca_file
|
||||||
self.proxy_token = proxy_token
|
self.proxy_token = proxy_token
|
||||||
|
self.enrollment_db = enrollment_db
|
||||||
self.lock = threading.Lock()
|
self.lock = threading.Lock()
|
||||||
self.sessions: dict[str, Session] = {}
|
self.sessions: dict[str, Session] = {}
|
||||||
|
self.enrollments: dict[str, Enrollment] = {}
|
||||||
|
self._load_enrollments()
|
||||||
|
|
||||||
def _now(self) -> float:
|
def _now(self) -> float:
|
||||||
return time.time()
|
return time.time()
|
||||||
|
|
@ -84,6 +95,48 @@ class ProxyState:
|
||||||
self._gc_locked()
|
self._gc_locked()
|
||||||
return len(self.sessions)
|
return len(self.sessions)
|
||||||
|
|
||||||
|
def _load_enrollments(self) -> None:
|
||||||
|
if not self.enrollment_db.exists():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
payload = json.loads(self.enrollment_db.read_text())
|
||||||
|
users = payload.get("users", [])
|
||||||
|
for item in users:
|
||||||
|
username = str(item.get("username", "")).strip()
|
||||||
|
if not username:
|
||||||
|
continue
|
||||||
|
enrolled_at = int(item.get("enrolled_at", int(self._now())))
|
||||||
|
self.enrollments[username] = Enrollment(username=username, enrolled_at=enrolled_at)
|
||||||
|
except Exception:
|
||||||
|
self.enrollments = {}
|
||||||
|
|
||||||
|
def _save_enrollments_locked(self) -> None:
|
||||||
|
self.enrollment_db.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
users = [
|
||||||
|
{"username": enrollment.username, "enrolled_at": enrollment.enrolled_at}
|
||||||
|
for enrollment in sorted(self.enrollments.values(), key=lambda item: item.username)
|
||||||
|
]
|
||||||
|
self.enrollment_db.write_text(json.dumps({"users": users}, indent=2) + "\n")
|
||||||
|
|
||||||
|
def register_enrollment(self, username: str) -> tuple[bool, Enrollment]:
|
||||||
|
username = username.strip()
|
||||||
|
enrolled_at = int(self._now())
|
||||||
|
with self.lock:
|
||||||
|
existing = self.enrollments.get(username)
|
||||||
|
if existing:
|
||||||
|
return False, existing
|
||||||
|
enrollment = Enrollment(username=username, enrolled_at=enrolled_at)
|
||||||
|
self.enrollments[username] = enrollment
|
||||||
|
self._save_enrollments_locked()
|
||||||
|
return True, enrollment
|
||||||
|
|
||||||
|
def get_enrollment(self, username: str) -> Enrollment | None:
|
||||||
|
with self.lock:
|
||||||
|
return self.enrollments.get(username.strip())
|
||||||
|
|
||||||
|
def has_enrollment(self, username: str) -> bool:
|
||||||
|
return self.get_enrollment(username) is not None
|
||||||
|
|
||||||
def authenticate_with_card(self) -> tuple[bool, str]:
|
def authenticate_with_card(self) -> tuple[bool, str]:
|
||||||
try:
|
try:
|
||||||
proc = subprocess.run(
|
proc = subprocess.run(
|
||||||
|
|
@ -179,6 +232,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
if path.startswith("/enroll/status"):
|
||||||
|
self._enroll_status()
|
||||||
|
return
|
||||||
self.send_error(404)
|
self.send_error(404)
|
||||||
|
|
||||||
def do_POST(self) -> None: # noqa: N802
|
def do_POST(self) -> None: # noqa: N802
|
||||||
|
|
@ -186,6 +242,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
if path == "/session/login":
|
if path == "/session/login":
|
||||||
self._session_login()
|
self._session_login()
|
||||||
return
|
return
|
||||||
|
if path == "/enroll/register":
|
||||||
|
self._enroll_register()
|
||||||
|
return
|
||||||
if path == "/session/status":
|
if path == "/session/status":
|
||||||
self._session_status()
|
self._session_status()
|
||||||
return
|
return
|
||||||
|
|
@ -208,6 +267,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
if not username:
|
if not username:
|
||||||
self._json(400, {"ok": False, "error": "username required"})
|
self._json(400, {"ok": False, "error": "username required"})
|
||||||
return
|
return
|
||||||
|
if not self.state.has_enrollment(username):
|
||||||
|
self._json(403, {"ok": False, "error": "user not enrolled", "username": username})
|
||||||
|
return
|
||||||
|
|
||||||
ok, message = self.state.authenticate_with_card()
|
ok, message = self.state.authenticate_with_card()
|
||||||
if not ok:
|
if not ok:
|
||||||
|
|
@ -227,6 +289,55 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _enroll_register(self) -> None:
|
||||||
|
try:
|
||||||
|
data = self._read_json()
|
||||||
|
except Exception:
|
||||||
|
self._json(400, {"ok": False, "error": "invalid json"})
|
||||||
|
return
|
||||||
|
|
||||||
|
username = str(data.get("username", "")).strip()
|
||||||
|
if not username:
|
||||||
|
self._json(400, {"ok": False, "error": "username required"})
|
||||||
|
return
|
||||||
|
|
||||||
|
created, enrollment = self.state.register_enrollment(username)
|
||||||
|
self._json(
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"username": enrollment.username,
|
||||||
|
"enrolled_at": enrollment.enrolled_at,
|
||||||
|
"created": created,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _enroll_status(self) -> None:
|
||||||
|
parsed = urlparse(self.path)
|
||||||
|
query = {}
|
||||||
|
if parsed.query:
|
||||||
|
for chunk in parsed.query.split("&"):
|
||||||
|
if "=" not in chunk:
|
||||||
|
continue
|
||||||
|
key, value = chunk.split("=", 1)
|
||||||
|
query[key] = value
|
||||||
|
username = query.get("username", "").strip()
|
||||||
|
if not username:
|
||||||
|
self._json(400, {"ok": False, "error": "username query required"})
|
||||||
|
return
|
||||||
|
enrollment = self.state.get_enrollment(username)
|
||||||
|
if not enrollment:
|
||||||
|
self._json(404, {"ok": False, "error": "user not enrolled", "username": username})
|
||||||
|
return
|
||||||
|
self._json(
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"username": enrollment.username,
|
||||||
|
"enrolled_at": enrollment.enrolled_at,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
def _session_status(self) -> None:
|
def _session_status(self) -> None:
|
||||||
got = self._require_session()
|
got = self._require_session()
|
||||||
if not got:
|
if not got:
|
||||||
|
|
@ -296,6 +407,11 @@ def parse_args() -> argparse.Namespace:
|
||||||
default="dev-proxy-token",
|
default="dev-proxy-token",
|
||||||
help="Shared token to authorize requests to k_server",
|
help="Shared token to authorize requests to k_server",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--enrollment-db",
|
||||||
|
default="/home/user/chromecard/k_proxy_enrollments.json",
|
||||||
|
help="JSON file used to persist enrolled usernames for the prototype",
|
||||||
|
)
|
||||||
return parser.parse_args()
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -312,6 +428,7 @@ def main() -> int:
|
||||||
server_base_url=args.server_base_url,
|
server_base_url=args.server_base_url,
|
||||||
server_ca_file=args.server_ca_file,
|
server_ca_file=args.server_ca_file,
|
||||||
proxy_token=args.proxy_token,
|
proxy_token=args.proxy_token,
|
||||||
|
enrollment_db=Path(args.enrollment_db),
|
||||||
)
|
)
|
||||||
Handler.state = state
|
Handler.state = state
|
||||||
server = ThreadingHTTPServer((args.host, args.port), Handler)
|
server = ThreadingHTTPServer((args.host, args.port), Handler)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue