802 lines
26 KiB
Python
802 lines
26 KiB
Python
#!/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 Flow</title>
|
|
<style>
|
|
:root {
|
|
--bg: #f3efe8;
|
|
--panel: #fffdf8;
|
|
--ink: #181614;
|
|
--muted: #655f56;
|
|
--line: #d9cfbf;
|
|
--accent: #0c6a60;
|
|
--accent-2: #8a5b2b;
|
|
--ok: #17653c;
|
|
--warn: #8f5b00;
|
|
--bad: #8a1f28;
|
|
--shadow: rgba(55, 41, 19, 0.08);
|
|
}
|
|
* { box-sizing: border-box; }
|
|
body {
|
|
margin: 0;
|
|
font-family: "Iowan Old Style", "Palatino Linotype", serif;
|
|
background:
|
|
radial-gradient(circle at top left, rgba(12,106,96,0.12), transparent 34%),
|
|
linear-gradient(180deg, #f9f3e8 0%, var(--bg) 100%);
|
|
color: var(--ink);
|
|
}
|
|
main {
|
|
max-width: 980px;
|
|
margin: 0 auto;
|
|
padding: 32px 20px 56px;
|
|
}
|
|
.hero, .panel {
|
|
padding: 22px 24px;
|
|
border: 1px solid var(--line);
|
|
background: linear-gradient(135deg, rgba(255,253,248,0.98), rgba(242,237,228,0.94));
|
|
box-shadow: 0 18px 40px var(--shadow);
|
|
}
|
|
.hero {
|
|
margin-bottom: 18px;
|
|
}
|
|
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: minmax(0, 1.3fr) minmax(300px, 0.9fr);
|
|
gap: 18px;
|
|
align-items: start;
|
|
}
|
|
.stack {
|
|
display: grid;
|
|
gap: 18px;
|
|
}
|
|
.actions, .row {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 10px;
|
|
}
|
|
.actions {
|
|
margin-top: 18px;
|
|
}
|
|
input {
|
|
width: 100%;
|
|
padding: 10px 12px;
|
|
border: 1px solid var(--line);
|
|
background: #fff;
|
|
font: inherit;
|
|
color: var(--ink);
|
|
}
|
|
label {
|
|
display: grid;
|
|
gap: 6px;
|
|
margin-top: 14px;
|
|
color: var(--muted);
|
|
font-size: 0.95rem;
|
|
}
|
|
button {
|
|
text-decoration: none;
|
|
border: 0;
|
|
padding: 10px 14px;
|
|
font: inherit;
|
|
color: #fff;
|
|
background: var(--accent);
|
|
cursor: pointer;
|
|
}
|
|
button.secondary { background: var(--accent-2); }
|
|
button.ghost {
|
|
background: #fff;
|
|
color: var(--ink);
|
|
border: 1px solid var(--line);
|
|
}
|
|
button:disabled {
|
|
opacity: 0.55;
|
|
cursor: wait;
|
|
}
|
|
.status {
|
|
display: grid;
|
|
gap: 12px;
|
|
}
|
|
.status-card {
|
|
padding: 14px;
|
|
border: 1px solid var(--line);
|
|
background: rgba(255,255,255,0.86);
|
|
}
|
|
.status-card h2 {
|
|
margin: 0 0 6px;
|
|
font-size: 1rem;
|
|
}
|
|
.status-line {
|
|
font-size: 0.95rem;
|
|
color: var(--muted);
|
|
}
|
|
#usersList {
|
|
display: grid;
|
|
gap: 8px;
|
|
margin-top: 12px;
|
|
}
|
|
.user-row {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 10px 12px;
|
|
border: 1px solid var(--line);
|
|
background: rgba(255,255,255,0.86);
|
|
}
|
|
.user-meta {
|
|
display: grid;
|
|
gap: 2px;
|
|
}
|
|
.user-name {
|
|
font-weight: 600;
|
|
}
|
|
.user-subtle {
|
|
color: var(--muted);
|
|
font-size: 0.9rem;
|
|
}
|
|
.user-actions {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 8px;
|
|
}
|
|
.small {
|
|
padding: 8px 10px;
|
|
font-size: 0.92rem;
|
|
}
|
|
.badge {
|
|
display: inline-block;
|
|
padding: 4px 8px;
|
|
border: 1px solid var(--line);
|
|
font-size: 0.86rem;
|
|
background: #fff;
|
|
color: var(--ink);
|
|
margin-right: 6px;
|
|
margin-bottom: 6px;
|
|
}
|
|
.timeline {
|
|
display: grid;
|
|
gap: 10px;
|
|
margin-top: 16px;
|
|
}
|
|
.step {
|
|
display: grid;
|
|
grid-template-columns: 32px 1fr;
|
|
gap: 12px;
|
|
padding: 12px;
|
|
border: 1px solid var(--line);
|
|
background: rgba(255,255,255,0.84);
|
|
}
|
|
.step-index {
|
|
width: 32px;
|
|
height: 32px;
|
|
display: grid;
|
|
place-items: center;
|
|
border-radius: 999px;
|
|
border: 1px solid var(--line);
|
|
background: #fff;
|
|
font-size: 0.88rem;
|
|
}
|
|
.hint {
|
|
margin-top: 14px;
|
|
padding: 12px 14px;
|
|
border-left: 4px solid var(--accent-2);
|
|
background: rgba(138,91,43,0.08);
|
|
color: var(--ink);
|
|
font-size: 0.95rem;
|
|
}
|
|
pre {
|
|
margin: 0;
|
|
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;
|
|
min-height: 360px;
|
|
}
|
|
@media (max-width: 860px) {
|
|
.grid { grid-template-columns: 1fr; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main>
|
|
<section class="hero">
|
|
<h1>ChromeCard Client Flow</h1>
|
|
<p class="subtitle">
|
|
This page runs in `k_client` and drives the real split-VM flow:
|
|
register a user, ask the card in `k_proxy` for approval, and then call
|
|
the protected counter on `k_server` only if auth succeeds.
|
|
</p>
|
|
</section>
|
|
|
|
<div class="grid">
|
|
<section class="stack">
|
|
<section class="panel">
|
|
<div class="row">
|
|
<span class="badge">Browser: k_client</span>
|
|
<span class="badge">Card: k_proxy</span>
|
|
<span class="badge">Resource: k_server</span>
|
|
</div>
|
|
|
|
<label>
|
|
Username
|
|
<input id="username" value="directtest" autocomplete="off">
|
|
</label>
|
|
|
|
<div class="actions">
|
|
<button id="registerBtn">Register User</button>
|
|
<button id="loginBtn">Login</button>
|
|
<button id="counterBtn">Call k_server</button>
|
|
<button id="logoutBtn" class="secondary">Logout</button>
|
|
<button id="runFlowBtn" class="ghost">Run Full Flow</button>
|
|
<button id="refreshBtn" class="ghost">Refresh State</button>
|
|
</div>
|
|
|
|
<div class="hint" id="hintBox">
|
|
Registration: press <strong>yes</strong> on the card to enroll.
|
|
Login: press <strong>yes</strong> to allow the identity check, or
|
|
<strong>no</strong> to deny it. If login is denied, this page will
|
|
show that `k_server` was not called.
|
|
</div>
|
|
|
|
<div class="timeline">
|
|
<div class="step">
|
|
<div class="step-index">1</div>
|
|
<div>
|
|
<strong>Register user</strong><br>
|
|
Creates or refreshes the enrolled identity in `k_proxy`.
|
|
</div>
|
|
</div>
|
|
<div class="step">
|
|
<div class="step-index">2</div>
|
|
<div>
|
|
<strong>Authenticate with the card</strong><br>
|
|
`k_proxy` asks the card for approval. Press `yes` to continue or `no` to reject.
|
|
</div>
|
|
</div>
|
|
<div class="step">
|
|
<div class="step-index">3</div>
|
|
<div>
|
|
<strong>Call `k_server`</strong><br>
|
|
The protected counter is only reached when login created a valid session.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel status">
|
|
<div class="status-card">
|
|
<h2>Client State</h2>
|
|
<div class="status-line" id="stateUser">Enrolled user: unknown</div>
|
|
<div class="status-line" id="stateSession">Session: unknown</div>
|
|
<div class="status-line" id="stateExpires">Expires: unknown</div>
|
|
</div>
|
|
<div class="status-card">
|
|
<h2>Registered Users</h2>
|
|
<div class="status-line" id="usersSummary">Loading users...</div>
|
|
<div id="usersList"></div>
|
|
</div>
|
|
<div class="status-card">
|
|
<h2>Flow Result</h2>
|
|
<div class="status-line" id="flowResult">No flow run yet.</div>
|
|
</div>
|
|
</section>
|
|
</section>
|
|
|
|
<section class="panel">
|
|
<h2 style="margin-top:0">Event Log</h2>
|
|
<pre id="log"></pre>
|
|
</section>
|
|
</div>
|
|
</main>
|
|
|
|
<script>
|
|
const logNode = document.getElementById("log");
|
|
const hintBox = document.getElementById("hintBox");
|
|
const flowResult = document.getElementById("flowResult");
|
|
const stateUser = document.getElementById("stateUser");
|
|
const stateSession = document.getElementById("stateSession");
|
|
const stateExpires = document.getElementById("stateExpires");
|
|
const usersSummary = document.getElementById("usersSummary");
|
|
const usersList = document.getElementById("usersList");
|
|
const usernameInput = document.getElementById("username");
|
|
const buttons = Array.from(document.querySelectorAll("button"));
|
|
|
|
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;
|
|
}
|
|
|
|
function setBusy(busy) {
|
|
for (const button of buttons) button.disabled = busy;
|
|
}
|
|
|
|
function username() {
|
|
return usernameInput.value.trim();
|
|
}
|
|
|
|
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();
|
|
return {status: resp.status, data};
|
|
}
|
|
|
|
async function refreshState() {
|
|
const resp = await fetch("/api/client/state");
|
|
const data = await resp.json();
|
|
stateUser.textContent = `Enrolled user: ${data.enrolled_username || "none"}`;
|
|
stateSession.textContent = `Session active: ${data.session_active ? "yes" : "no"}`;
|
|
stateExpires.textContent = `Expires: ${data.session_expires_at || "none"}`;
|
|
return data;
|
|
}
|
|
|
|
function renderUsers(users) {
|
|
usersList.innerHTML = "";
|
|
if (!users.length) {
|
|
usersSummary.textContent = "No registered users in k_proxy.";
|
|
return;
|
|
}
|
|
usersSummary.textContent = `${users.length} registered user${users.length === 1 ? "" : "s"} visible in k_proxy.`;
|
|
for (const user of users) {
|
|
const row = document.createElement("div");
|
|
row.className = "user-row";
|
|
|
|
const meta = document.createElement("div");
|
|
meta.className = "user-meta";
|
|
meta.innerHTML =
|
|
`<div class="user-name">${user.username}</div>` +
|
|
`<div class="user-subtle">Credential present: ${user.has_credential ? "yes" : "no"}</div>`;
|
|
|
|
const actions = document.createElement("div");
|
|
actions.className = "user-actions";
|
|
|
|
const useBtn = document.createElement("button");
|
|
useBtn.className = "ghost small";
|
|
useBtn.textContent = "Use";
|
|
useBtn.addEventListener("click", () => {
|
|
usernameInput.value = user.username;
|
|
flowResult.textContent = `Selected user ${user.username}.`;
|
|
});
|
|
|
|
const deleteBtn = document.createElement("button");
|
|
deleteBtn.className = "secondary small";
|
|
deleteBtn.textContent = "Unregister";
|
|
deleteBtn.addEventListener("click", async () => {
|
|
setBusy(true);
|
|
try { await deleteUser(user.username); } finally { setBusy(false); }
|
|
});
|
|
|
|
actions.appendChild(useBtn);
|
|
actions.appendChild(deleteBtn);
|
|
row.appendChild(meta);
|
|
row.appendChild(actions);
|
|
usersList.appendChild(row);
|
|
}
|
|
}
|
|
|
|
async function refreshUsers() {
|
|
const resp = await fetch("/api/enrollments");
|
|
const data = await resp.json();
|
|
renderUsers(data.users || []);
|
|
return data;
|
|
}
|
|
|
|
async function registerUser() {
|
|
hintBox.innerHTML = "Card step: if the card shows a <strong>registration</strong> prompt, press <strong>yes</strong> to enroll this user.";
|
|
const result = await api("/api/enroll", {username: username()});
|
|
log("Register user", result);
|
|
flowResult.textContent = result.status === 200 ? "User registration succeeded." : "User registration failed.";
|
|
await refreshState();
|
|
await refreshUsers();
|
|
return result;
|
|
}
|
|
|
|
async function loginUser() {
|
|
hintBox.innerHTML = "Card step: if the card shows an <strong>authentication</strong> prompt, press <strong>yes</strong> to allow login or <strong>no</strong> to deny it.";
|
|
const result = await api("/api/login", {username: username()});
|
|
log("Login", result);
|
|
await refreshState();
|
|
return result;
|
|
}
|
|
|
|
async function callCounter() {
|
|
const result = await api("/api/resource/counter", {});
|
|
log("Call k_server counter", result);
|
|
flowResult.textContent =
|
|
result.status === 200
|
|
? `k_server was reached. Counter value: ${result.data.upstream?.value}`
|
|
: "k_server was not reached successfully.";
|
|
return result;
|
|
}
|
|
|
|
async function logoutUser() {
|
|
const result = await api("/api/logout", {});
|
|
log("Logout", result);
|
|
flowResult.textContent = result.status === 200 ? "Session cleared." : "Logout failed.";
|
|
await refreshState();
|
|
return result;
|
|
}
|
|
|
|
async function deleteUser(usernameToDelete) {
|
|
const result = await api("/api/enroll/delete", {username: usernameToDelete});
|
|
log("Unregister user", result);
|
|
flowResult.textContent =
|
|
result.status === 200
|
|
? `User ${usernameToDelete} was unregistered.`
|
|
: `Could not unregister ${usernameToDelete}.`;
|
|
if (result.status === 200 && username() === usernameToDelete) {
|
|
usernameInput.value = "";
|
|
}
|
|
await refreshState();
|
|
await refreshUsers();
|
|
return result;
|
|
}
|
|
|
|
async function runFlow() {
|
|
setBusy(true);
|
|
flowResult.textContent = "Flow running...";
|
|
try {
|
|
const login = await loginUser();
|
|
if (login.status !== 200) {
|
|
flowResult.textContent = "Login denied or failed. `k_server` was not called.";
|
|
log("Flow stopped before k_server", {
|
|
reason: "login failed",
|
|
status: login.status,
|
|
response: login.data
|
|
});
|
|
return;
|
|
}
|
|
const counter = await callCounter();
|
|
if (counter.status === 200) {
|
|
flowResult.textContent = `Flow succeeded. k_server returned counter ${counter.data.upstream?.value}.`;
|
|
} else {
|
|
flowResult.textContent = "Login succeeded, but the protected k_server call failed.";
|
|
}
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
document.getElementById("registerBtn").addEventListener("click", async () => {
|
|
setBusy(true);
|
|
try { await registerUser(); } finally { setBusy(false); }
|
|
});
|
|
document.getElementById("loginBtn").addEventListener("click", async () => {
|
|
setBusy(true);
|
|
try {
|
|
const result = await loginUser();
|
|
flowResult.textContent = result.status === 200 ? "Login succeeded. You can now call k_server." : "Login denied or failed. k_server was not called.";
|
|
} finally { setBusy(false); }
|
|
});
|
|
document.getElementById("counterBtn").addEventListener("click", async () => {
|
|
setBusy(true);
|
|
try { await callCounter(); } finally { setBusy(false); }
|
|
});
|
|
document.getElementById("logoutBtn").addEventListener("click", async () => {
|
|
setBusy(true);
|
|
try { await logoutUser(); } finally { setBusy(false); }
|
|
});
|
|
document.getElementById("runFlowBtn").addEventListener("click", runFlow);
|
|
document.getElementById("refreshBtn").addEventListener("click", async () => {
|
|
setBusy(true);
|
|
try {
|
|
const state = await refreshState();
|
|
const users = await refreshUsers();
|
|
log("State refreshed", {state, users});
|
|
} finally { setBusy(false); }
|
|
});
|
|
|
|
Promise.all([refreshState(), refreshUsers()]).then(([state, users]) => {
|
|
log("Client flow page ready", {state, users});
|
|
});
|
|
</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 list_enrollments(self) -> tuple[int, dict[str, Any]]:
|
|
return self._proxy_json("GET", "/enroll/list")
|
|
|
|
def delete_enrollment(self, username: str) -> tuple[int, dict[str, Any]]:
|
|
username = username.strip()
|
|
if not username:
|
|
return 400, {"ok": False, "error": "username required"}
|
|
status, data = self._proxy_json("POST", "/enroll/delete", {"username": username})
|
|
if status == 200:
|
|
with self.lock:
|
|
if self.preferred_enrollment and self.preferred_enrollment.username == username:
|
|
self.preferred_enrollment = None
|
|
self._save_preferred_enrollment_locked()
|
|
self.session_token = None
|
|
self.session_expires_at = None
|
|
return status, 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, username: str | None = None) -> tuple[int, dict[str, Any]]:
|
|
requested = (username or "").strip()
|
|
with self.lock:
|
|
if requested:
|
|
username = requested
|
|
elif self.preferred_enrollment:
|
|
username = self.preferred_enrollment.username
|
|
else:
|
|
return 400, {"ok": False, "error": "no enrolled user"}
|
|
|
|
status, data = self._proxy_json("POST", "/session/login", {"username": username})
|
|
if status == 200 and data.get("session_token"):
|
|
with self.lock:
|
|
self.preferred_enrollment = EnrollmentRecord(username=username)
|
|
self._save_preferred_enrollment_locked()
|
|
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
|
|
if path == "/api/enrollments":
|
|
status, data = self.state.list_enrollments()
|
|
self._json(status, data)
|
|
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":
|
|
try:
|
|
data = self._read_json()
|
|
except Exception:
|
|
self._json(400, {"ok": False, "error": "invalid json"})
|
|
return
|
|
status, data = self.state.login(str(data.get("username", "")))
|
|
self._json(status, data)
|
|
return
|
|
if path == "/api/enroll/delete":
|
|
try:
|
|
data = self._read_json()
|
|
except Exception:
|
|
self._json(400, {"ok": False, "error": "invalid json"})
|
|
return
|
|
status, data = self.state.delete_enrollment(str(data.get("username", "")))
|
|
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())
|