307 lines
9.6 KiB
Python
307 lines
9.6 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Minimal k_proxy service for Phase 5 bring-up.
|
|
|
|
Behavior:
|
|
- Creates short-lived sessions after a card-presence check.
|
|
- Reuses valid sessions to access k_server protected counter endpoint.
|
|
- Supports session status and logout.
|
|
|
|
Notes:
|
|
- Session login uses `fido2_probe.py --json` command success as auth gate for now.
|
|
- This is a Phase 5 starter and not a final production auth design.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import secrets
|
|
import subprocess
|
|
import threading
|
|
import time
|
|
from dataclasses import dataclass
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from typing import Any
|
|
from urllib.error import HTTPError, URLError
|
|
from urllib.parse import urlparse
|
|
from urllib.request import Request, urlopen
|
|
|
|
|
|
@dataclass
|
|
class Session:
|
|
username: str
|
|
expires_at: float
|
|
|
|
|
|
class ProxyState:
|
|
def __init__(
|
|
self,
|
|
session_ttl_s: int,
|
|
auth_command: str,
|
|
server_base_url: str,
|
|
proxy_token: str,
|
|
):
|
|
self.session_ttl_s = session_ttl_s
|
|
self.auth_command = auth_command
|
|
self.server_base_url = server_base_url.rstrip("/")
|
|
self.proxy_token = proxy_token
|
|
self.lock = threading.Lock()
|
|
self.sessions: dict[str, Session] = {}
|
|
|
|
def _now(self) -> float:
|
|
return time.time()
|
|
|
|
def _gc_locked(self) -> None:
|
|
now = self._now()
|
|
dead = [token for token, sess in self.sessions.items() if sess.expires_at <= now]
|
|
for token in dead:
|
|
del self.sessions[token]
|
|
|
|
def create_session(self, username: str) -> tuple[str, float]:
|
|
token = secrets.token_urlsafe(32)
|
|
now = self._now()
|
|
expires_at = now + self.session_ttl_s
|
|
with self.lock:
|
|
self._gc_locked()
|
|
self.sessions[token] = Session(username=username, expires_at=expires_at)
|
|
return token, expires_at
|
|
|
|
def get_session(self, token: str) -> Session | None:
|
|
with self.lock:
|
|
self._gc_locked()
|
|
return self.sessions.get(token)
|
|
|
|
def invalidate_session(self, token: str) -> bool:
|
|
with self.lock:
|
|
return self.sessions.pop(token, None) is not None
|
|
|
|
def active_session_count(self) -> int:
|
|
with self.lock:
|
|
self._gc_locked()
|
|
return len(self.sessions)
|
|
|
|
def authenticate_with_card(self) -> tuple[bool, str]:
|
|
try:
|
|
proc = subprocess.run(
|
|
self.auth_command,
|
|
shell=True,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=10,
|
|
check=False,
|
|
)
|
|
except Exception as exc:
|
|
return False, f"auth command failed: {exc}"
|
|
|
|
if proc.returncode != 0:
|
|
stderr = proc.stderr.strip()
|
|
stdout = proc.stdout.strip()
|
|
details = stderr if stderr else stdout
|
|
return False, details or f"auth command exit code {proc.returncode}"
|
|
|
|
return True, "card presence check succeeded"
|
|
|
|
def fetch_counter(self) -> tuple[int, dict[str, Any]]:
|
|
url = f"{self.server_base_url}/resource/counter"
|
|
req = Request(url, method="POST")
|
|
req.add_header("X-Proxy-Token", self.proxy_token)
|
|
req.add_header("Content-Type", "application/json")
|
|
body = b"{}"
|
|
try:
|
|
with urlopen(req, data=body, timeout=5) as resp:
|
|
data = json.loads(resp.read().decode("utf-8"))
|
|
return resp.status, data
|
|
except HTTPError as exc:
|
|
try:
|
|
data = json.loads(exc.read().decode("utf-8"))
|
|
except Exception:
|
|
data = {"ok": False, "error": f"server http error {exc.code}"}
|
|
return exc.code, data
|
|
except URLError as exc:
|
|
return 502, {"ok": False, "error": f"server unavailable: {exc.reason}"}
|
|
except Exception as exc:
|
|
return 502, {"ok": False, "error": f"server call failed: {exc}"}
|
|
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
state: ProxyState
|
|
|
|
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 _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 _bearer_token(self) -> str | None:
|
|
value = self.headers.get("Authorization", "")
|
|
if not value.startswith("Bearer "):
|
|
return None
|
|
token = value[7:].strip()
|
|
return token or None
|
|
|
|
def _require_session(self) -> tuple[str, Session] | None:
|
|
token = self._bearer_token()
|
|
if not token:
|
|
self._json(401, {"ok": False, "error": "missing bearer token"})
|
|
return None
|
|
session = self.state.get_session(token)
|
|
if not session:
|
|
self._json(401, {"ok": False, "error": "invalid or expired session"})
|
|
return None
|
|
return token, session
|
|
|
|
def do_GET(self) -> None: # noqa: N802
|
|
path = urlparse(self.path).path
|
|
if path == "/health":
|
|
self._json(
|
|
200,
|
|
{
|
|
"ok": True,
|
|
"service": "k_proxy",
|
|
"active_sessions": self.state.active_session_count(),
|
|
"time": int(time.time()),
|
|
},
|
|
)
|
|
return
|
|
self.send_error(404)
|
|
|
|
def do_POST(self) -> None: # noqa: N802
|
|
path = urlparse(self.path).path
|
|
if path == "/session/login":
|
|
self._session_login()
|
|
return
|
|
if path == "/session/status":
|
|
self._session_status()
|
|
return
|
|
if path == "/session/logout":
|
|
self._session_logout()
|
|
return
|
|
if path == "/resource/counter":
|
|
self._resource_counter()
|
|
return
|
|
self.send_error(404)
|
|
|
|
def _session_login(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
|
|
|
|
ok, message = self.state.authenticate_with_card()
|
|
if not ok:
|
|
self._json(401, {"ok": False, "error": "card auth failed", "details": message})
|
|
return
|
|
|
|
token, expires_at = self.state.create_session(username)
|
|
self._json(
|
|
200,
|
|
{
|
|
"ok": True,
|
|
"username": username,
|
|
"session_token": token,
|
|
"expires_at": int(expires_at),
|
|
"ttl_seconds": self.state.session_ttl_s,
|
|
"auth_mode": "card_presence_probe",
|
|
},
|
|
)
|
|
|
|
def _session_status(self) -> None:
|
|
got = self._require_session()
|
|
if not got:
|
|
return
|
|
_, session = got
|
|
self._json(
|
|
200,
|
|
{
|
|
"ok": True,
|
|
"username": session.username,
|
|
"expires_at": int(session.expires_at),
|
|
"seconds_remaining": max(0, int(session.expires_at - time.time())),
|
|
},
|
|
)
|
|
|
|
def _session_logout(self) -> None:
|
|
token = self._bearer_token()
|
|
if not token:
|
|
self._json(401, {"ok": False, "error": "missing bearer token"})
|
|
return
|
|
removed = self.state.invalidate_session(token)
|
|
self._json(200, {"ok": True, "invalidated": removed})
|
|
|
|
def _resource_counter(self) -> None:
|
|
got = self._require_session()
|
|
if not got:
|
|
return
|
|
_, session = got
|
|
status, upstream = self.state.fetch_counter()
|
|
if status != 200:
|
|
self._json(status, {"ok": False, "error": "upstream failed", "upstream": upstream})
|
|
return
|
|
self._json(
|
|
200,
|
|
{
|
|
"ok": True,
|
|
"username": session.username,
|
|
"session_reused": True,
|
|
"upstream": upstream,
|
|
},
|
|
)
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(description="Run k_proxy session gateway")
|
|
parser.add_argument("--host", default="127.0.0.1")
|
|
parser.add_argument("--port", type=int, default=8770)
|
|
parser.add_argument("--session-ttl", type=int, default=300, help="Session TTL in seconds")
|
|
parser.add_argument(
|
|
"--auth-command",
|
|
default="python3 /home/user/chromecard/fido2_probe.py --json",
|
|
help="Command used for session creation auth gate",
|
|
)
|
|
parser.add_argument(
|
|
"--server-base-url",
|
|
default="http://127.0.0.1:8780",
|
|
help="Base URL for k_server",
|
|
)
|
|
parser.add_argument(
|
|
"--proxy-token",
|
|
default="dev-proxy-token",
|
|
help="Shared token to authorize requests to k_server",
|
|
)
|
|
return parser.parse_args()
|
|
|
|
|
|
def main() -> int:
|
|
args = parse_args()
|
|
state = ProxyState(
|
|
session_ttl_s=args.session_ttl,
|
|
auth_command=args.auth_command,
|
|
server_base_url=args.server_base_url,
|
|
proxy_token=args.proxy_token,
|
|
)
|
|
Handler.state = state
|
|
server = ThreadingHTTPServer((args.host, args.port), Handler)
|
|
print(f"k_proxy listening on http://{args.host}:{args.port}")
|
|
server.serve_forever()
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|