t3-afk: auto-pair dispatcher sidecar — no manual pairing
All checks were successful
ci/woodpecker/push/default Pipeline was successful
All checks were successful
ci/woodpecker/push/default Pipeline was successful
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 <noreply@anthropic.com>
This commit is contained in:
parent
92c5b24975
commit
34c30ac2bf
2 changed files with 198 additions and 1 deletions
136
stacks/t3-afk/files/dispatcher.js
Normal file
136
stacks/t3-afk/files/dispatcher.js
Normal file
|
|
@ -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}`),
|
||||
);
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue