From 34c30ac2bf416f0320713f9b025e22e47af37f4b Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Mon, 15 Jun 2026 20:19:39 +0000 Subject: [PATCH] =?UTF-8?q?t3-afk:=20auto-pair=20dispatcher=20sidecar=20?= =?UTF-8?q?=E2=80=94=20no=20manual=20pairing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bare `t3 serve` behind Authentik showed the manual /pair#token screen, which didn't connect. Mirror the devvm t3-dispatch: a small stdlib-Node sidecar fronts t3 serve, and on a cookieless (already Authentik-gated) document load it mints a pairing credential (`t3 auth pairing create`) and exchanges it at /api/auth/browser-session for the t3_session cookie, then 302s back. Everything else — including WebSocket upgrades for the live cockpit — reverse-proxies to :3773. The Service now targets the sidecar (:8080). Verified: cookieless GET -> 302 + Set-Cookie t3_session; cookied GET -> 200 SPA. Matches the t3.viktorbarzin.me experience (Authentik login -> straight into the cockpit). Co-Authored-By: Claude Opus 4.8 --- stacks/t3-afk/files/dispatcher.js | 136 ++++++++++++++++++++++++++++++ stacks/t3-afk/main.tf | 63 +++++++++++++- 2 files changed, 198 insertions(+), 1 deletion(-) create mode 100644 stacks/t3-afk/files/dispatcher.js diff --git a/stacks/t3-afk/files/dispatcher.js b/stacks/t3-afk/files/dispatcher.js new file mode 100644 index 00000000..6cc9a800 --- /dev/null +++ b/stacks/t3-afk/files/dispatcher.js @@ -0,0 +1,136 @@ +// t3-afk auto-pair dispatcher +// ---------------------------------------------------------------------------- +// Replicates the devvm t3-dispatch experience for the single in-cluster T3 +// instance. The ingress is Authentik-gated (auth=required), so every request +// that reaches here is already authenticated. On a cookieless *document* +// navigation we mint a one-time pairing credential (`t3 auth pairing create`) +// and exchange it at the t3 server's /api/auth/browser-session endpoint for the +// `t3_session` cookie, then 302 back — so the user never sees the manual +// /pair#token screen. Everything else (incl. WebSocket upgrades for the cockpit +// live stream + terminals) is reverse-proxied straight through to t3 serve. +// +// Single upstream, same pod (localhost) — kept dependency-free (Node stdlib). +'use strict'; +const http = require('http'); +const net = require('net'); +const { execFile } = require('child_process'); + +const UPSTREAM_HOST = '127.0.0.1'; +const UPSTREAM_PORT = Number(process.env.T3_UPSTREAM_PORT || 3773); +const LISTEN_PORT = Number(process.env.DISPATCHER_PORT || 8080); +const T3_BIN = process.env.T3_BIN || '/data/npm-global/bin/t3'; +const BASE_DIR = process.env.T3CODE_HOME || '/data/t3'; +const COOKIE = 't3_session'; +const childEnv = { ...process.env, PATH: '/data/npm-global/bin:' + (process.env.PATH || ''), HOME: '/home/node' }; + +const hasSession = (req) => + (req.headers.cookie || '').split(/;\s*/).some((c) => c.startsWith(COOKIE + '=')); + +const isDocNav = (req) => { + if (req.method !== 'GET') return false; + const dest = req.headers['sec-fetch-dest']; + if (dest) return dest === 'document'; + return (req.headers['accept'] || '').includes('text/html'); +}; + +const mintCredential = () => + new Promise((resolve, reject) => { + execFile( + T3_BIN, + ['auth', 'pairing', 'create', '--base-dir', BASE_DIR, '--ttl', '5m', '--json'], + { env: childEnv, timeout: 15000 }, + (err, stdout) => { + if (err) return reject(err); + try { + const cred = JSON.parse(stdout).credential; + cred ? resolve(cred) : reject(new Error('no credential in pairing output')); + } catch (e) { + reject(e); + } + }, + ); + }); + +const exchange = (credential) => + new Promise((resolve, reject) => { + const body = JSON.stringify({ credential }); + const r = http.request( + { + host: UPSTREAM_HOST, + port: UPSTREAM_PORT, + path: '/api/auth/browser-session', + method: 'POST', + headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) }, + }, + (resp) => { + const setCookie = resp.headers['set-cookie'] || []; + resp.resume(); + resp.on('end', () => + resp.statusCode === 200 && setCookie.length + ? resolve(setCookie) + : reject(new Error('browser-session exchange returned ' + resp.statusCode)), + ); + }, + ); + r.on('error', reject); + r.write(body); + r.end(); + }); + +const proxyHttp = (req, res) => { + const up = http.request( + { host: UPSTREAM_HOST, port: UPSTREAM_PORT, path: req.url, method: req.method, headers: req.headers }, + (r) => { + res.writeHead(r.statusCode, r.headers); + r.pipe(res); + }, + ); + up.on('error', () => { + if (!res.headersSent) res.writeHead(502); + res.end('bad gateway'); + }); + req.pipe(up); +}; + +const server = http.createServer(async (req, res) => { + if (req.url === '/healthz') { + res.writeHead(200); + return res.end('ok'); + } + if (!hasSession(req) && isDocNav(req)) { + try { + const cred = await mintCredential(); + const setCookie = await exchange(cred); + res.writeHead(302, { location: req.url || '/', 'set-cookie': setCookie, 'cache-control': 'no-store' }); + return res.end(); + } catch (err) { + // Fall through to a plain proxy; the cockpit's own /pair screen is the + // fallback if auto-pair ever fails, so we never hard-fail the request. + console.error('auto-pair failed, proxying through:', err.message); + } + } + proxyHttp(req, res); +}); + +// WebSocket / Upgrade passthrough — the cockpit's live orchestration stream and +// terminals need this. Reconstruct the upgrade request and splice the sockets. +server.on('upgrade', (req, socket, head) => { + const up = net.connect(UPSTREAM_PORT, UPSTREAM_HOST, () => { + up.write( + `${req.method} ${req.url} HTTP/1.1\r\n` + + Object.entries(req.headers) + .map(([k, v]) => `${k}: ${v}`) + .join('\r\n') + + '\r\n\r\n', + ); + if (head && head.length) up.write(head); + socket.pipe(up); + up.pipe(socket); + }); + up.on('error', () => socket.destroy()); + socket.on('error', () => up.destroy()); +}); + +server.listen(LISTEN_PORT, '0.0.0.0', () => + console.log(`t3-afk dispatcher listening on :${LISTEN_PORT} -> ${UPSTREAM_HOST}:${UPSTREAM_PORT}`), +); diff --git a/stacks/t3-afk/main.tf b/stacks/t3-afk/main.tf index a56cffde..063e42ad 100644 --- a/stacks/t3-afk/main.tf +++ b/stacks/t3-afk/main.tf @@ -107,6 +107,21 @@ resource "kubernetes_config_map" "agent_claudemd" { } } +# Auto-pair dispatcher script (run by the sidecar container below). Mirrors the +# devvm t3-dispatch: on a cookieless, Authentik-gated page load it mints a +# pairing credential and exchanges it for the t3_session cookie, so the user +# never sees the manual /pair screen. Reverse-proxies everything else (incl. +# WebSockets) to t3 serve. +resource "kubernetes_config_map" "dispatcher" { + metadata { + name = "t3-afk-dispatcher" + namespace = kubernetes_namespace.t3_afk.metadata[0].name + } + data = { + "dispatcher.js" = file("${path.module}/files/dispatcher.js") + } +} + # --- Storage --- # SSD-NFS (small-file friendly) for the T3 base dir: state.sqlite + the # server-signing-key (losing it invalidates every issued bearer), per-thread git @@ -300,6 +315,43 @@ resource "kubernetes_deployment" "t3_afk" { } } + # Auto-pair dispatcher (sidecar). The Service points at this (:8080); it + # reverse-proxies to t3 serve (:3773) and injects the session cookie so + # the browser experience matches t3.viktorbarzin.me. Shares /data so it + # can exec the t3 CLI to mint pairing credentials. + container { + name = "dispatcher" + image = local.image + command = ["node", "/scripts/dispatcher.js"] + port { + container_port = 8080 + } + env { + name = "HOME" + value = "/home/node" + } + readiness_probe { + http_get { + path = "/healthz" + port = 8080 + } + initial_delay_seconds = 10 + period_seconds = 10 + } + volume_mount { + name = "data" + mount_path = "/data" + } + volume_mount { + name = "dispatcher" + mount_path = "/scripts" + } + resources { + requests = { cpu = "50m", memory = "64Mi" } + limits = { memory = "256Mi" } + } + } + volume { name = "data" persistent_volume_claim { @@ -313,6 +365,13 @@ resource "kubernetes_deployment" "t3_afk" { name = kubernetes_config_map.agent_claudemd.metadata[0].name } } + + volume { + name = "dispatcher" + config_map { + name = kubernetes_config_map.dispatcher.metadata[0].name + } + } } } } @@ -339,9 +398,11 @@ resource "kubernetes_service" "t3_afk" { } spec { selector = local.labels + # Route to the auto-pair dispatcher sidecar (:8080), which reverse-proxies + # to t3 serve (:3773) after injecting the t3_session cookie. port { port = 3773 - target_port = 3773 + target_port = 8080 } type = "ClusterIP" }