initial relay scaffold

This commit is contained in:
local
2026-05-11 20:03:27 -05:00
commit b9d86fa303
58 changed files with 7609 additions and 0 deletions
+127
View File
@@ -0,0 +1,127 @@
// Admin dashboard auth. Mirrors Recap's server/admin-auth.js shape so
// the patterns are familiar. Single-user auth (operator only) — verify
// scrypt(password, salt) against the stored hash, mint a signed
// session cookie on success. Cookie is HMAC-signed with a per-install
// session secret so attackers can't forge sessions even with read
// access to the relay's /admin endpoints.
import { scryptSync, timingSafeEqual, createHmac } from "crypto";
import { getConfigSnapshot } from "./config.js";
const SCRYPT_KEYLEN = 64;
const SESSION_COOKIE = "recap-relay-admin";
const SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24h
// Path prefix that requires admin auth. Public /relay/* paths are
// authenticated per-call by the route handlers, not the cookie.
const ADMIN_PREFIX = "/admin";
function constantTimeEqual(a, b) {
if (typeof a !== "string" || typeof b !== "string") return false;
if (a.length !== b.length) return false;
try {
return timingSafeEqual(Buffer.from(a), Buffer.from(b));
} catch {
return false;
}
}
function signSessionToken(payload, secret) {
const body = JSON.stringify(payload);
const sig = createHmac("sha256", secret).update(body).digest("hex");
return `${Buffer.from(body).toString("base64url")}.${sig}`;
}
function verifySessionToken(token, secret) {
if (!token) return null;
const parts = token.split(".");
if (parts.length !== 2) return null;
const [b64, sig] = parts;
let body;
try {
body = Buffer.from(b64, "base64url").toString("utf8");
} catch {
return null;
}
const expectedSig = createHmac("sha256", secret).update(body).digest("hex");
if (!constantTimeEqual(sig, expectedSig)) return null;
let payload;
try {
payload = JSON.parse(body);
} catch {
return null;
}
if (typeof payload?.exp !== "number" || Date.now() > payload.exp) return null;
return payload;
}
export function setupAdminAuthMiddleware(app) {
app.use(async (req, res, next) => {
if (!req.path.startsWith(ADMIN_PREFIX)) return next();
// /admin/login is reachable without auth.
if (req.path === "/admin/login" || req.path === "/admin/status") return next();
const cfg = await getConfigSnapshot();
if (!cfg.relay_admin_password_hash) {
// No password set — admin endpoints are disabled entirely.
return res.status(401).json({ error: "admin_disabled" });
}
const token = req.cookies?.[SESSION_COOKIE];
const payload = verifySessionToken(token, cfg.relay_admin_session_secret);
if (!payload) return res.status(401).json({ error: "unauthorized" });
req.adminUser = payload.user;
next();
});
}
export function setupAdminAuthRoutes(app) {
app.get("/admin/status", async (_req, res) => {
const cfg = await getConfigSnapshot();
res.json({
enabled: !!cfg.relay_admin_password_hash,
username: cfg.relay_admin_username || "admin",
});
});
app.post("/admin/login", async (req, res) => {
const cfg = await getConfigSnapshot();
if (!cfg.relay_admin_password_hash) {
return res.status(400).json({ error: "admin_disabled" });
}
const { username, password } = req.body || {};
if (
!username ||
!password ||
typeof username !== "string" ||
typeof password !== "string"
) {
return res.status(400).json({ error: "missing_credentials" });
}
if (username.trim() !== (cfg.relay_admin_username || "admin")) {
return res.status(401).json({ error: "invalid_credentials" });
}
const hash = scryptSync(password, cfg.relay_admin_password_salt, SCRYPT_KEYLEN).toString("hex");
if (!constantTimeEqual(hash, cfg.relay_admin_password_hash)) {
return res.status(401).json({ error: "invalid_credentials" });
}
const token = signSessionToken(
{ user: username, exp: Date.now() + SESSION_TTL_MS },
cfg.relay_admin_session_secret
);
res.cookie(SESSION_COOKIE, token, {
httpOnly: true,
sameSite: "lax",
// secure: true would be ideal but the relay runs behind
// StartTunnel which terminates TLS — the cookie travels over
// plain HTTP inside the tunnel. Leave secure false so the
// cookie sticks; the tunnel itself provides the encryption.
secure: false,
maxAge: SESSION_TTL_MS,
});
res.json({ ok: true, username });
});
app.post("/admin/logout", (_req, res) => {
res.clearCookie(SESSION_COOKIE);
res.json({ ok: true });
});
}