From e69bab3f029ddedd9587aabceed1dc14a6fc19a7 Mon Sep 17 00:00:00 2001 From: valentinkolb Date: Fri, 26 Jun 2026 14:32:58 +0200 Subject: [PATCH 01/49] docs: highlight admin ui --- docs-site/docs/en/admin.md | 34 ++++++++++++++++----------------- docs-site/docs/en/operations.md | 2 ++ 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/docs-site/docs/en/admin.md b/docs-site/docs/en/admin.md index 408cbcb..e540724 100644 --- a/docs-site/docs/en/admin.md +++ b/docs-site/docs/en/admin.md @@ -1,17 +1,17 @@ --- -title: Admin app -navTitle: Admin app -section: Use Filegate -order: 65 -description: Run the standalone Filegate admin app for browser-based operations. +title: Admin UI +navTitle: Admin UI +section: Operate +order: 105 +description: Use the Filegate admin UI for browser-based file operations and service inspection. tags: [admin, ui] --- -# Admin app +# Admin UI -The admin app is a standalone SSR web app for operators who need browser access to Filegate resources and service state. +The admin UI is for operators who need browser access to Filegate files, metadata, activity, and runtime state. -Filegate itself serves REST and optional S3 APIs. The admin app runs as a separate process and talks to Filegate through the TypeScript client. +It runs as a separate SSR app next to Filegate. File bytes still move through Filegate; the UI keeps the Filegate bearer token on the admin server. ## Runtime model @@ -22,6 +22,15 @@ browser <-> Filegate direct upload/download URLs The Filegate bearer token stays on the admin server. Browser uploads and downloads use scoped direct URLs. +## Use it for + +| Page | Scope | Use for | +|---|---:|---| +| Overview | Service | Mount and storage summary. | +| Files | Mount and node | Browse, upload, download, create folders, transfer, rename, edit POSIX metadata, and delete. | +| Search | Service index | Glob search over indexed paths. | +| System | Service | Metrics, index state, cache pressure, activity, and index rescan. | + ## Environment | Variable | Scope | Required | Meaning | @@ -47,15 +56,6 @@ bun run dev Open `http://127.0.0.1:3000` and sign in with `ADMIN_TOKEN`. -## Admin surfaces - -| Page | Scope | Use for | -|---|---:|---| -| Overview | Service | Mount and storage summary. | -| Files | Mount and node | Browse, upload, download, create folders, transfer, rename, edit metadata, delete. | -| Search | Service index | Glob search over indexed paths. | -| System | Service | Metrics, index state, cache pressure, activity log, and index rescan. | - ## Browser transfer behavior | Operation | Data path | Meaning | diff --git a/docs-site/docs/en/operations.md b/docs-site/docs/en/operations.md index e4893b6..cb9c52b 100644 --- a/docs-site/docs/en/operations.md +++ b/docs-site/docs/en/operations.md @@ -11,6 +11,8 @@ tags: [operations, systemd, index] This page is for operators running Filegate as a service. +Use the [Admin UI](admin) for browser-based file operations, runtime metrics, activity inspection, and index rescans. + ## Service lifecycle | Task | Scope | Command | From 1ee94c82668bfd62ea4fe5a62ebfdb757c546fdb Mon Sep 17 00:00:00 2001 From: valentinkolb Date: Sat, 25 Jul 2026 21:02:09 +0200 Subject: [PATCH 02/49] feat(admin): per-user sessions and login hardening Replace the constant-HMAC session cookie with a signed payload carrying subject, label, kind and expiry, verified server-side on every request. This is the foundation OIDC plugs into. Close three login weaknesses: - ADMIN_TOKEN no longer falls back to FILEGATE_TOKEN, so brute forcing the admin login can no longer yield the Filegate master token. Startup fails when the two are equal. - Rate limit POST /login to 10 attempts per 5 minutes per client via @valentinkolb/sync. In-memory by default, Redis-backed when REDIS_URL is set. Note that Bun resolves REDIS_URL at process start. - Drive the Secure cookie flag from ADMIN_COOKIE_SECURE instead of the request URL protocol, which is http behind a TLS-terminating ingress. X-Forwarded-For is only trusted with ADMIN_TRUST_PROXY, otherwise a client could pick a fresh rate-limit bucket per request. Also upgrades @valentinkolb/ssr to 0.11.2 and stdlib to 0.16.0, adds DOM.Iterable to tsconfig so typecheck passes, and sets up bun test with an SSR plugin preload. BREAKING CHANGE: ADMIN_TOKEN is now required and must differ from FILEGATE_TOKEN. Existing session cookies are invalidated. --- admin/README.md | 37 ++++++++- admin/bun.lock | 15 ++-- admin/bunfig.toml | 2 + admin/package.json | 9 ++- admin/src/app.tsx | 15 +++- admin/src/lib/auth.ts | 62 +++++++++----- admin/src/lib/env.ts | 78 +++++++++++++++--- admin/src/lib/format.ts | 5 ++ admin/src/lib/ratelimit.ts | 53 ++++++++++++ admin/src/lib/request.ts | 53 ++++++++++++ admin/src/lib/session.ts | 103 ++++++++++++++++++++++++ admin/src/server.tsx | 4 +- admin/test/login.test.ts | 160 +++++++++++++++++++++++++++++++++++++ admin/test/preload.ts | 6 ++ admin/test/session.test.ts | 69 ++++++++++++++++ admin/tsconfig.json | 2 +- docs-site/docs/en/admin.md | 26 +++++- 17 files changed, 649 insertions(+), 50 deletions(-) create mode 100644 admin/bunfig.toml create mode 100644 admin/src/lib/ratelimit.ts create mode 100644 admin/src/lib/request.ts create mode 100644 admin/src/lib/session.ts create mode 100644 admin/test/login.test.ts create mode 100644 admin/test/preload.ts create mode 100644 admin/test/session.test.ts diff --git a/admin/README.md b/admin/README.md index 46a1afe..4f3861a 100644 --- a/admin/README.md +++ b/admin/README.md @@ -10,11 +10,42 @@ the TypeScript client. The browser only receives an admin session cookie. ```bash cd admin bun install -FILEGATE_URL=http://127.0.0.1:18080 FILEGATE_TOKEN=dev-token bun run dev +FILEGATE_URL=http://127.0.0.1:18080 \ +FILEGATE_TOKEN=dev-token \ +ADMIN_TOKEN=dev-admin \ +ADMIN_SESSION_SECRET=dev-session-secret \ +bun run dev ``` -Open `http://127.0.0.1:3000` and sign in with `ADMIN_TOKEN` when set, otherwise -with `FILEGATE_TOKEN`. +Open `http://127.0.0.1:3000` and sign in with `ADMIN_TOKEN`. + +## Configuration + +| Variable | Required | Meaning | +|---|---:|---| +| `FILEGATE_URL` | yes | REST API base URL, reachable from the admin server. | +| `FILEGATE_TOKEN` | yes | Filegate bearer token, kept server-side. | +| `ADMIN_TOKEN` | yes | Admin login token. Must differ from `FILEGATE_TOKEN`. | +| `ADMIN_SESSION_SECRET` | no | Session signing secret. Generated at boot when unset, which means sessions survive neither a restart nor a second replica. Set it in production. | +| `PORT` | no | Listen port, default `3000`. | +| `ADMIN_TRUST_PROXY` | no | Set when a reverse proxy sits in front, so `X-Forwarded-For` is used for rate limiting instead of the socket address. | +| `ADMIN_COOKIE_SECURE` | no | `auto` (default), `true` or `false`. Auto marks the session cookie `Secure` unless the request host is localhost. | +| `REDIS_URL` | no | Enables shared rate limiting across replicas. In-memory otherwise. | + +`ADMIN_TOKEN` no longer falls back to `FILEGATE_TOKEN`, and startup fails when +the two are equal: sharing them means guessing the admin login hands out the +Filegate master credential. `ADMIN_SESSION_SECRET` must likewise differ from both. + +## Sessions and login + +Sign-in issues a stateless signed session cookie carrying subject, label and +expiry, verified server-side on every request. There is no session store to run. + +`POST /login` is rate limited to 10 attempts per 5 minutes per client. The limiter +is in-memory by default; setting `REDIS_URL` switches it to a Redis-backed one +that is shared across replicas. Note that `REDIS_URL` must be present in the +process environment at launch, since the Redis connection is resolved from it at +startup and not re-read later. ## Uploads diff --git a/admin/bun.lock b/admin/bun.lock index 7f0723d..74623d8 100644 --- a/admin/bun.lock +++ b/admin/bun.lock @@ -6,8 +6,9 @@ "name": "@valentinkolb/filegate-admin", "dependencies": { "@valentinkolb/filegate": "file:../sdk/ts", - "@valentinkolb/ssr": "^0.10.0", - "@valentinkolb/stdlib": "^0.13.0", + "@valentinkolb/ssr": "^0.11.2", + "@valentinkolb/stdlib": "^0.16.0", + "@valentinkolb/sync": "^5.6.0", "hono": "^4.12.25", "solid-js": "^1.9.13", }, @@ -98,9 +99,11 @@ "@valentinkolb/filegate": ["@valentinkolb/filegate@file:../sdk/ts", { "devDependencies": { "typescript": "^5.8" } }], - "@valentinkolb/ssr": ["@valentinkolb/ssr@0.10.0", "", { "dependencies": { "@babel/core": "^7.24.0", "@babel/preset-typescript": "^7.24.0", "@types/babel__core": "^7.20.5", "babel-preset-solid": "^1.8.0", "seroval": "^1.0.0" }, "peerDependencies": { "@elysiajs/static": "^1.0.0", "elysia": "^1.0.0", "hono": "^4.0.0", "solid-js": "^1.9.0" }, "optionalPeers": ["@elysiajs/static", "elysia", "hono"] }, "sha512-X+o39uWUmfoqwAT9NA1Gaag5yZ5EKSz9kzfxctWJY2frhOOGF2YVZN7Zauwua1TbpsYcpt9GwCsZqXmj6QpjMg=="], + "@valentinkolb/ssr": ["@valentinkolb/ssr@0.11.2", "", { "dependencies": { "@babel/core": "^7.29.7", "@babel/preset-typescript": "^7.29.7", "@types/babel__core": "^7.20.5", "babel-preset-solid": "^1.9.12", "seroval": "^1.5.5" }, "peerDependencies": { "elysia": "^1.0.0", "hono": "^4.0.0", "solid-js": "^1.9.0" }, "optionalPeers": ["elysia", "hono"] }, "sha512-tL6youm0IcD8AETxgDtfgxBiBlCweE3WUL2Cj3ofmMWjgT9GslhLfH++8el/synBu27CAZO5+5D+F8L+svk4ig=="], - "@valentinkolb/stdlib": ["@valentinkolb/stdlib@0.13.0", "", { "dependencies": { "dayjs": "^1.11.0" }, "peerDependencies": { "lean-qr": "", "solid-js": "" }, "optionalPeers": ["lean-qr", "solid-js"] }, "sha512-dj4XWF0yvGp4drf3rHHUDq0LiVc9nrKCqwZJJxtfi4nXVhnZWfBC4nfSSl4W1MVCpWQJDUoBS/VpH8wAIo+Y9Q=="], + "@valentinkolb/stdlib": ["@valentinkolb/stdlib@0.16.0", "", { "dependencies": { "dayjs": "^1.11.0" }, "peerDependencies": { "lean-qr": "", "solid-js": "" }, "optionalPeers": ["lean-qr", "solid-js"] }, "sha512-tNz+oIv//82RuLArP2jFPvRL/SPHmM5yKr/5pGSJsFQ1NH86s/kCK6K263J/hbztWh+rwojuRPUJm7a0QvN08A=="], + + "@valentinkolb/sync": ["@valentinkolb/sync@5.6.0", "", {}, "sha512-BzUNyxHcgWc+2VtNB3P6EfhjOXYWei1hhUsn2jfztQFgmnW4QmlfBFHXcTfzGJwNCXFpsKyDYCRC9UZxK3FOCQ=="], "babel-plugin-jsx-dom-expressions": ["babel-plugin-jsx-dom-expressions@0.40.7", "", { "dependencies": { "@babel/helper-module-imports": "7.18.6", "@babel/plugin-syntax-jsx": "^7.18.6", "@babel/types": "^7.20.7", "html-entities": "2.3.3", "parse5": "^7.1.2" }, "peerDependencies": { "@babel/core": "^7.20.12" } }, "sha512-/O6JWUmjv03OI9lL2ry9bUjpD5S3PclM55RRJEyCdcFZ5W2SEA/59d+l2hNsk3gI6kiWRdRPdOtqZmsQzFN1pQ=="], @@ -152,7 +155,7 @@ "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "seroval": ["seroval@1.5.4", "", {}, "sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw=="], + "seroval": ["seroval@1.5.6", "", {}, "sha512-rVQVWjjSvlINzaQPZH5JFqsqEsIWdTxY3iJZCnTL/5gQbXIRooVZKI60tVCkOVfzcRPejboxO2t0P89dg5mQaA=="], "seroval-plugins": ["seroval-plugins@1.5.4", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw=="], @@ -167,5 +170,7 @@ "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="], + + "solid-js/seroval": ["seroval@1.5.4", "", {}, "sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw=="], } } diff --git a/admin/bunfig.toml b/admin/bunfig.toml new file mode 100644 index 0000000..786a377 --- /dev/null +++ b/admin/bunfig.toml @@ -0,0 +1,2 @@ +[test] +preload = ["./test/preload.ts"] diff --git a/admin/package.json b/admin/package.json index 3c641f9..eb4888d 100644 --- a/admin/package.json +++ b/admin/package.json @@ -7,12 +7,15 @@ "dev": "bun run build && bun dist/server.js", "build": "bun run build:sdk && bun run src/build.ts", "build:sdk": "bunx tsc -p ../sdk/ts/tsconfig.json && rm -rf node_modules/@valentinkolb/filegate/dist && cp -R ../sdk/ts/dist node_modules/@valentinkolb/filegate/dist", - "start": "bun dist/server.js" + "start": "bun dist/server.js", + "test": "bun test", + "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { "@valentinkolb/filegate": "file:../sdk/ts", - "@valentinkolb/ssr": "^0.10.0", - "@valentinkolb/stdlib": "^0.13.0", + "@valentinkolb/ssr": "^0.11.2", + "@valentinkolb/stdlib": "^0.16.0", + "@valentinkolb/sync": "^5.6.0", "hono": "^4.12.25", "solid-js": "^1.9.13" }, diff --git a/admin/src/app.tsx b/admin/src/app.tsx index f989e20..797b725 100644 --- a/admin/src/app.tsx +++ b/admin/src/app.tsx @@ -20,7 +20,7 @@ import { Hono } from "hono"; import { login, logout, requireAuth } from "./lib/auth"; import { client, isList, parentPath, resolveDirectory } from "./lib/filegate"; import { env } from "./lib/env"; -import { errorMessage, redirectFiles, selectedFiles } from "./lib/format"; +import { errorMessage, formatRetryAfter, redirectFiles, selectedFiles } from "./lib/format"; import { config, routes, ssr } from "./config"; import { LoginPage } from "./components/Layout"; import { readThemeFromCookieHeader, type AdminTheme } from "./lib/theme"; @@ -64,8 +64,8 @@ export const app = new Hono() "/login", ...ssr(async (c) => { setPage(c, "Sign in"); - const hasError = c.req.query("error") === "invalid"; - return () => ; + const error = loginError(c.req.query("error"), c.req.query("retry")); + return () => ; }), ) .post("/login", login) @@ -208,6 +208,15 @@ export const app = new Hono() return c.redirect("/system?notice=rescan+started", 303); }); +function loginError(code: string | undefined, retry: string | undefined): string | undefined { + if (code === "invalid") return "Invalid admin token"; + if (code === "throttled") { + const wait = formatRetryAfter(Number(retry)); + return wait ? `Too many sign-in attempts. Try again in ${wait}.` : "Too many sign-in attempts. Try again later."; + } + return undefined; +} + function setPage(c: { get(key: "page"): { title?: string; theme?: AdminTheme }; req: { header(name: string): string | undefined } }, title: string) { const page = c.get("page"); page.title = title; diff --git a/admin/src/lib/auth.ts b/admin/src/lib/auth.ts index 9407027..9db043c 100644 --- a/admin/src/lib/auth.ts +++ b/admin/src/lib/auth.ts @@ -1,17 +1,14 @@ -import { createHmac, timingSafeEqual } from "node:crypto"; +import { timingSafeEqual } from "node:crypto"; import type { Context, MiddlewareHandler } from "hono"; import { deleteCookie, getCookie, setCookie } from "hono/cookie"; import { env } from "./env"; +import { recordLoginAttempt } from "./ratelimit"; +import { clientId, useSecureCookie } from "./request"; +import { issueSession, readSession, sessionCookieName, sessionTtlSeconds, type AdminSession } from "./session"; -const cookieName = "filegate_admin"; - -function sessionValue(): string { - const cfg = env(); - return createHmac("sha256", cfg.sessionSecret) - .update("filegate-admin-session-v1:") - .update(cfg.adminToken) - .digest("hex"); -} +/** Subject used for the shared-token login; OIDC logins carry the IdP subject. */ +const tokenSubject = "local-admin"; +const tokenLabel = "Local admin"; function equal(a: string, b: string): boolean { const ab = Buffer.from(a); @@ -19,35 +16,56 @@ function equal(a: string, b: string): boolean { return ab.length === bb.length && timingSafeEqual(ab, bb); } -export function authorized(c: Context): boolean { - const cookie = getCookie(c, cookieName); - return !!cookie && equal(cookie, sessionValue()); +/** The verified session for this request, or null when unauthenticated. */ +export function currentSession(c: Context): AdminSession | null { + const existing = c.get("session"); + if (existing) return existing; + + const session = readSession(getCookie(c, sessionCookieName)); + if (session) c.set("session", session); + return session; } export function requireAuth(): MiddlewareHandler { return async (c, next) => { - if (authorized(c)) return next(); + if (currentSession(c)) return next(); + // Clear a cookie that is present but no longer valid, so an expired session + // does not keep bouncing off the login page with a stale cookie attached. + if (getCookie(c, sessionCookieName)) deleteCookie(c, sessionCookieName, { path: "/" }); return c.redirect("/login", 303); }; } +function establish(c: Context, input: { sub: string; label: string; kind: AdminSession["kind"] }): void { + const { value } = issueSession(input); + setCookie(c, sessionCookieName, value, { + httpOnly: true, + sameSite: "Strict", + secure: useSecureCookie(c), + path: "/", + maxAge: sessionTtlSeconds, + }); +} + export async function login(c: Context): Promise { + // Count the attempt before checking the token, so a wrong guess costs budget. + const attempt = await recordLoginAttempt(clientId(c)); + if (attempt.limited) { + c.header("Retry-After", String(attempt.retryAfterSeconds)); + return c.redirect(`/login?error=throttled&retry=${attempt.retryAfterSeconds}`, 303); + } + const body = await c.req.parseBody(); const token = String(body.token || ""); if (!equal(token, env().adminToken)) { return c.redirect("/login?error=invalid", 303); } - setCookie(c, cookieName, sessionValue(), { - httpOnly: true, - sameSite: "Strict", - secure: new URL(c.req.url).protocol === "https:", - path: "/", - maxAge: 60 * 60 * 12, - }); + + establish(c, { sub: tokenSubject, label: tokenLabel, kind: "token" }); return c.redirect("/", 303); } export function logout(c: Context): Response { - deleteCookie(c, cookieName, { path: "/" }); + deleteCookie(c, sessionCookieName, { path: "/" }); return c.redirect("/login", 303); } diff --git a/admin/src/lib/env.ts b/admin/src/lib/env.ts index 29acf1e..30ff434 100644 --- a/admin/src/lib/env.ts +++ b/admin/src/lib/env.ts @@ -1,24 +1,82 @@ +import { randomBytes } from "node:crypto"; + +export type CookieSecureMode = "auto" | "always" | "never"; + export type AdminEnv = { filegateUrl: string; filegateToken: string; adminToken: string; sessionSecret: string; port: number; + trustProxy: boolean; + cookieSecure: CookieSecureMode; + redisUrl?: string; }; -function required(name: string): string { +function required(name: string, hint: string): string { const value = Bun.env[name]?.trim(); - if (!value) throw new Error(`${name} is required`); + if (!value) throw new Error(`${name} is required: ${hint}`); return value; } -export function env(): AdminEnv { - const filegateToken = required("FILEGATE_TOKEN"); - return { - filegateUrl: required("FILEGATE_URL"), - filegateToken, - adminToken: Bun.env.ADMIN_TOKEN?.trim() || filegateToken, - sessionSecret: Bun.env.ADMIN_SESSION_SECRET?.trim() || filegateToken, - port: Number(Bun.env.PORT || 3000), +function boolFlag(name: string): boolean { + const value = Bun.env[name]?.trim().toLowerCase(); + return value === "1" || value === "true" || value === "yes"; +} + +function cookieSecureMode(): CookieSecureMode { + const value = Bun.env.ADMIN_COOKIE_SECURE?.trim().toLowerCase(); + if (!value || value === "auto") return "auto"; + if (value === "1" || value === "true" || value === "yes") return "always"; + if (value === "0" || value === "false" || value === "no") return "never"; + throw new Error(`ADMIN_COOKIE_SECURE must be auto, true or false, got ${value}`); +} + +// The session secret must stay stable for the process lifetime, otherwise every +// request would verify against a different key. When it is not configured we +// generate one and warn: sessions then survive neither a restart nor a second +// replica, which is fine for local use and wrong for a real deployment. +function resolveSessionSecret(): string { + const configured = Bun.env.ADMIN_SESSION_SECRET?.trim(); + if (configured) return configured; + console.warn( + "[filegate-admin] ADMIN_SESSION_SECRET is not set; using a random secret. Sessions will not survive a restart and will not work across replicas.", + ); + return randomBytes(32).toString("hex"); +} + +function resolve(): AdminEnv { + const port = Number(Bun.env.PORT || 3000); + if (!Number.isInteger(port) || port <= 0 || port > 65535) { + throw new Error(`PORT must be a valid port number, got ${Bun.env.PORT}`); + } + + const cfg: AdminEnv = { + filegateUrl: required("FILEGATE_URL", "REST API base URL of the Filegate server"), + filegateToken: required("FILEGATE_TOKEN", "Filegate bearer token, kept server-side"), + // Deliberately no fallback to FILEGATE_TOKEN. Sharing them means brute + // forcing the admin login yields the Filegate master credential. + adminToken: required("ADMIN_TOKEN", "admin login token; must differ from FILEGATE_TOKEN"), + sessionSecret: resolveSessionSecret(), + port, + trustProxy: boolFlag("ADMIN_TRUST_PROXY"), + cookieSecure: cookieSecureMode(), + redisUrl: Bun.env.REDIS_URL?.trim() || undefined, }; + + if (cfg.adminToken === cfg.filegateToken) { + throw new Error("ADMIN_TOKEN must differ from FILEGATE_TOKEN so the admin login cannot leak the Filegate master token"); + } + if (cfg.sessionSecret === cfg.filegateToken || cfg.sessionSecret === cfg.adminToken) { + throw new Error("ADMIN_SESSION_SECRET must differ from FILEGATE_TOKEN and ADMIN_TOKEN"); + } + + return cfg; +} + +let cached: AdminEnv | undefined; + +export function env(): AdminEnv { + cached ??= resolve(); + return cached; } diff --git a/admin/src/lib/format.ts b/admin/src/lib/format.ts index 0acf46a..3eec8ca 100644 --- a/admin/src/lib/format.ts +++ b/admin/src/lib/format.ts @@ -5,6 +5,11 @@ export function formatBytes(value: number): string { return text.pprintBytes(value); } +export function formatRetryAfter(seconds: number): string | undefined { + if (!Number.isFinite(seconds) || seconds <= 0) return undefined; + return text.pprintDurationMs(seconds * 1000); +} + export function formatUnix(value: number): string { if (!value) return "-"; const millis = value > 100_000_000_000 ? value : value * 1000; diff --git a/admin/src/lib/ratelimit.ts b/admin/src/lib/ratelimit.ts new file mode 100644 index 0000000..96c0595 --- /dev/null +++ b/admin/src/lib/ratelimit.ts @@ -0,0 +1,53 @@ +import type { RateLimiter } from "@valentinkolb/sync"; +import { env } from "./env"; + +const LOGIN_LIMIT = 10; +const LOGIN_WINDOW_SECONDS = 5 * 60; + +/** + * The two @valentinkolb/sync entrypoints expose an identical rate-limit API, so + * the backend is a single import decision: + * + * - REDIS_URL set -> the server build, backed by Bun's Redis client, shared + * across replicas. + * - REDIS_URL unset -> the in-memory build, per process. Correct for a single + * instance and the sane default; with several replicas the + * effective limit multiplies by the replica count. + */ +async function createLoginLimiter(): Promise { + const { redisUrl } = env(); + const mod = redisUrl + ? await import("@valentinkolb/sync") + : await import("@valentinkolb/sync/browser"); + + console.log(`[filegate-admin] login rate limit: ${LOGIN_LIMIT} attempts per ${LOGIN_WINDOW_SECONDS}s per client (${redisUrl ? "redis" : "in-memory"})`); + + return mod.ratelimit({ + id: "admin-login", + limit: LOGIN_LIMIT, + windowSecs: LOGIN_WINDOW_SECONDS, + }); +} + +let pending: Promise | undefined; + +function loginLimiter(): Promise { + pending ??= createLoginLimiter(); + return pending; +} + +export type LoginAttemptVerdict = { + limited: boolean; + /** Seconds until the caller may try again; only meaningful when limited. */ + retryAfterSeconds: number; +}; + +/** Counts one login attempt against the client's window. */ +export async function recordLoginAttempt(clientId: string): Promise { + const limiter = await loginLimiter(); + const result = await limiter.check(clientId); + return { + limited: result.limited, + retryAfterSeconds: Math.max(1, Math.ceil(result.resetIn / 1000)), + }; +} diff --git a/admin/src/lib/request.ts b/admin/src/lib/request.ts new file mode 100644 index 0000000..ee67756 --- /dev/null +++ b/admin/src/lib/request.ts @@ -0,0 +1,53 @@ +import type { Context } from "hono"; +import { env } from "./env"; + +type BunServerEnv = { server?: { requestIP(req: Request): { address: string } | null } }; + +/** + * Rate-limit bucket key for the caller. + * + * X-Forwarded-For is only consulted when ADMIN_TRUST_PROXY is set, because any + * client can send that header: trusting it unconditionally would let an + * attacker pick a fresh bucket per request and defeat the limit entirely. + * Without a trusted proxy we use the socket address instead. + */ +export function clientId(c: Context): string { + if (env().trustProxy) { + const forwarded = c.req.header("x-forwarded-for")?.split(",")[0]?.trim(); + if (forwarded) return forwarded; + const real = c.req.header("x-real-ip")?.trim(); + if (real) return real; + } + + const address = (c.env as BunServerEnv | undefined)?.server?.requestIP(c.req.raw)?.address; + // Callers we cannot identify share one bucket. That is deliberately strict: + // it throttles rather than exempts them. + return address || "unknown"; +} + +const localHosts = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]); + +function isLocalRequest(c: Context): boolean { + const host = c.req.header("host")?.split(":")[0]?.trim().toLowerCase(); + return !!host && localHosts.has(host); +} + +/** + * Whether the session cookie should carry the Secure flag. + * + * The previous implementation derived this from the request URL's protocol, + * which is http inside the container behind a TLS-terminating ingress — so the + * cookie shipped without Secure exactly where it mattered most. Now the default + * is secure, with localhost as the only automatic exception so plain-http local + * development still works, and an explicit override for anything unusual. + */ +export function useSecureCookie(c: Context): boolean { + switch (env().cookieSecure) { + case "always": + return true; + case "never": + return false; + default: + return !isLocalRequest(c); + } +} diff --git a/admin/src/lib/session.ts b/admin/src/lib/session.ts new file mode 100644 index 0000000..1bb5b5f --- /dev/null +++ b/admin/src/lib/session.ts @@ -0,0 +1,103 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; +import { env } from "./env"; + +export const sessionCookieName = "filegate_admin"; +export const sessionTtlSeconds = 12 * 60 * 60; + +/** How the person at the keyboard proved who they are. */ +export type SessionKind = "token" | "oidc"; + +export type AdminSession = { + /** Stable subject. For token logins there is only one, for OIDC it is the IdP subject. */ + sub: string; + /** Human-readable label used in the UI and, later, in Filegate audit entries. */ + label: string; + kind: SessionKind; + issuedAt: number; + expiresAt: number; +}; + +declare module "hono" { + interface ContextVariableMap { + session: AdminSession; + } +} + +type SessionPayload = { + v: 1; + sub: string; + label: string; + kind: SessionKind; + iat: number; + exp: number; +}; + +function sign(payload: string): string { + return createHmac("sha256", env().sessionSecret).update(payload).digest("base64url"); +} + +function equal(a: string, b: string): boolean { + const ab = Buffer.from(a); + const bb = Buffer.from(b); + return ab.length === bb.length && timingSafeEqual(ab, bb); +} + +/** + * Issue a stateless signed session. The payload travels in the cookie so there + * is no session store to run; the signature is what makes it trustworthy, and + * expiresAt is verified server-side on every request rather than being left to + * the browser's cookie expiry. + */ +export function issueSession(input: { sub: string; label: string; kind: SessionKind; now?: number }): { + value: string; + session: AdminSession; +} { + const now = input.now ?? Date.now(); + const payload: SessionPayload = { + v: 1, + sub: input.sub, + label: input.label, + kind: input.kind, + iat: Math.floor(now / 1000), + exp: Math.floor(now / 1000) + sessionTtlSeconds, + }; + const encoded = Buffer.from(JSON.stringify(payload), "utf8").toString("base64url"); + return { + value: `${encoded}.${sign(encoded)}`, + session: toSession(payload), + }; +} + +/** Verify signature, shape and expiry. Returns null for anything untrustworthy. */ +export function readSession(value: string | undefined, now = Date.now()): AdminSession | null { + if (!value) return null; + const cut = value.lastIndexOf("."); + if (cut <= 0) return null; + + const encoded = value.slice(0, cut); + if (!equal(value.slice(cut + 1), sign(encoded))) return null; + + let payload: SessionPayload; + try { + payload = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")); + } catch { + return null; + } + + if (payload?.v !== 1) return null; + if (typeof payload.sub !== "string" || !payload.sub) return null; + if (payload.kind !== "token" && payload.kind !== "oidc") return null; + if (typeof payload.exp !== "number" || payload.exp * 1000 <= now) return null; + + return toSession(payload); +} + +function toSession(payload: SessionPayload): AdminSession { + return { + sub: payload.sub, + label: typeof payload.label === "string" && payload.label ? payload.label : payload.sub, + kind: payload.kind, + issuedAt: payload.iat * 1000, + expiresAt: payload.exp * 1000, + }; +} diff --git a/admin/src/server.tsx b/admin/src/server.tsx index eaa056d..658dc20 100644 --- a/admin/src/server.tsx +++ b/admin/src/server.tsx @@ -4,7 +4,9 @@ const { port } = runtimeEnv(); Bun.serve({ port, - fetch: app.fetch, + // The server object is handed to Hono as the request env so handlers can read + // the socket address for rate limiting; see lib/request.ts. + fetch: (req, server) => app.fetch(req, { server }), }); console.log(`filegate-admin listening on :${port}`); diff --git a/admin/test/login.test.ts b/admin/test/login.test.ts new file mode 100644 index 0000000..6f29867 --- /dev/null +++ b/admin/test/login.test.ts @@ -0,0 +1,160 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import type { Hono } from "hono"; + +const adminToken = "admin-token"; +let app: Hono; + +// Each test uses its own X-Forwarded-For so it gets its own rate-limit bucket. +// ADMIN_TRUST_PROXY makes that header authoritative, which is also the code path +// a deployment behind an ingress takes. +function request(path: string, init: RequestInit & { ip: string; cookie?: string }): Promise { + const headers = new Headers(init.headers); + headers.set("host", "localhost"); + headers.set("x-forwarded-for", init.ip); + if (init.cookie) headers.set("cookie", init.cookie); + return app.fetch(new Request(`http://localhost${path}`, { ...init, headers, redirect: "manual" })); +} + +function loginBody(token: string): RequestInit { + return { + method: "POST", + body: new URLSearchParams({ token }), + headers: { "content-type": "application/x-www-form-urlencoded" }, + }; +} + +function sessionCookie(res: Response): string { + const raw = res.headers.get("set-cookie") ?? ""; + return raw.split(";")[0] ?? ""; +} + +beforeAll(async () => { + Bun.env.FILEGATE_URL = "http://127.0.0.1:65535"; + Bun.env.FILEGATE_TOKEN = "filegate-token"; + Bun.env.ADMIN_TOKEN = adminToken; + Bun.env.ADMIN_SESSION_SECRET = "session-secret"; + Bun.env.ADMIN_TRUST_PROXY = "true"; + delete Bun.env.REDIS_URL; + + app = (await import("../src/app")).app; +}); + +describe("auth gate", () => { + test("unauthenticated requests are redirected to the login page", async () => { + const res = await request("/", { ip: "10.0.0.1" }); + + expect(res.status).toBe(303); + expect(res.headers.get("location")).toBe("/login"); + }); + + test("the login page and health endpoint stay public", async () => { + expect((await request("/login", { ip: "10.0.0.2" })).status).toBe(200); + expect((await request("/health", { ip: "10.0.0.2" })).status).toBe(200); + }); + + test("a forged cookie does not grant access", async () => { + const res = await request("/", { ip: "10.0.0.3", cookie: "filegate_admin=forged.value" }); + + expect(res.status).toBe(303); + expect(res.headers.get("location")).toBe("/login"); + }); +}); + +describe("token login", () => { + test("rejects a wrong token without issuing a cookie", async () => { + const res = await request("/login", { ip: "10.0.1.1", ...loginBody("wrong") }); + + expect(res.status).toBe(303); + expect(res.headers.get("location")).toBe("/login?error=invalid"); + expect(res.headers.get("set-cookie")).toBeNull(); + }); + + test("accepts the admin token and issues a hardened cookie", async () => { + const res = await request("/login", { ip: "10.0.1.2", ...loginBody(adminToken) }); + + expect(res.status).toBe(303); + expect(res.headers.get("location")).toBe("/"); + + const cookie = res.headers.get("set-cookie") ?? ""; + expect(cookie).toContain("filegate_admin="); + expect(cookie).toContain("HttpOnly"); + expect(cookie).toContain("SameSite=Strict"); + // Host is localhost, so the automatic mode must not mark the cookie Secure + // or the browser would refuse to send it back over plain http. + expect(cookie).not.toContain("Secure"); + }); + + test("the issued session opens authenticated pages", async () => { + const login = await request("/login", { ip: "10.0.1.3", ...loginBody(adminToken) }); + const res = await request("/", { ip: "10.0.1.3", cookie: sessionCookie(login) }); + + // Filegate is unreachable in this test, so the page renders its error + // banner. What matters here is that the session passed the auth gate. + expect(res.status).toBe(200); + }); + + test("logout clears the cookie", async () => { + const login = await request("/login", { ip: "10.0.1.4", ...loginBody(adminToken) }); + const res = await request("/logout", { ip: "10.0.1.4", method: "POST", cookie: sessionCookie(login) }); + const cookie = res.headers.get("set-cookie") ?? ""; + + expect(res.status).toBe(303); + expect(cookie).toContain("filegate_admin="); + expect(cookie).toMatch(/Max-Age=0|Expires=Thu, 01 Jan 1970/); + }); +}); + +describe("secure cookie mode", () => { + test("a non-localhost host gets a Secure cookie even over plain http", async () => { + const res = await app.fetch( + new Request("http://admin.example.com/login", { + method: "POST", + body: new URLSearchParams({ token: adminToken }), + headers: { + "content-type": "application/x-www-form-urlencoded", + "x-forwarded-for": "10.0.2.1", + host: "admin.example.com", + }, + redirect: "manual", + }), + ); + + // This is the ingress case: the process sees http, the browser sees https. + expect(res.headers.get("set-cookie")).toContain("Secure"); + }); +}); + +describe("login rate limit", () => { + test("throttles after the attempt budget is spent and reports Retry-After", async () => { + const ip = "10.0.3.1"; + const statuses: string[] = []; + + for (let i = 0; i < 11; i++) { + const res = await request("/login", { ip, ...loginBody("wrong") }); + statuses.push(res.headers.get("location") ?? ""); + if (i === 10) { + expect(res.headers.get("retry-after")).toBeTruthy(); + } + } + + expect(statuses.slice(0, 10).every((location) => location === "/login?error=invalid")).toBe(true); + expect(statuses[10]).toContain("error=throttled"); + }); + + test("a throttled client cannot log in even with the correct token", async () => { + const ip = "10.0.3.2"; + for (let i = 0; i < 10; i++) await request("/login", { ip, ...loginBody("wrong") }); + + const res = await request("/login", { ip, ...loginBody(adminToken) }); + expect(res.headers.get("location")).toContain("error=throttled"); + expect(res.headers.get("set-cookie")).toBeNull(); + }); + + test("the limit is per client, not global", async () => { + const noisy = "10.0.3.3"; + for (let i = 0; i < 11; i++) await request("/login", { ip: noisy, ...loginBody("wrong") }); + + const res = await request("/login", { ip: "10.0.3.4", ...loginBody(adminToken) }); + expect(res.headers.get("location")).toBe("/"); + }); +}); diff --git a/admin/test/preload.ts b/admin/test/preload.ts new file mode 100644 index 0000000..3a1e605 --- /dev/null +++ b/admin/test/preload.ts @@ -0,0 +1,6 @@ +// bun test runs the TSX sources directly, without the Bun.build step that +// normally applies the SSR plugin, so Solid's JSX transform has to be registered +// here or every rendered page throws "React is not defined". +import { plugin } from "../src/config"; + +Bun.plugin(plugin()); diff --git a/admin/test/session.test.ts b/admin/test/session.test.ts new file mode 100644 index 0000000..5f8cb38 --- /dev/null +++ b/admin/test/session.test.ts @@ -0,0 +1,69 @@ +import { beforeAll, describe, expect, test } from "bun:test"; + +let issueSession: typeof import("../src/lib/session").issueSession; +let readSession: typeof import("../src/lib/session").readSession; +let sessionTtlSeconds: number; + +beforeAll(async () => { + Bun.env.FILEGATE_URL = "http://127.0.0.1:65535"; + Bun.env.FILEGATE_TOKEN = "filegate-token"; + Bun.env.ADMIN_TOKEN = "admin-token"; + Bun.env.ADMIN_SESSION_SECRET = "session-secret"; + + const mod = await import("../src/lib/session"); + issueSession = mod.issueSession; + readSession = mod.readSession; + sessionTtlSeconds = mod.sessionTtlSeconds; +}); + +describe("session tokens", () => { + test("round-trips subject, label and kind", () => { + const { value } = issueSession({ sub: "user-1", label: "Ada", kind: "oidc" }); + const session = readSession(value); + + expect(session).not.toBeNull(); + expect(session?.sub).toBe("user-1"); + expect(session?.label).toBe("Ada"); + expect(session?.kind).toBe("oidc"); + }); + + test("two sessions for different subjects differ", () => { + const a = issueSession({ sub: "user-1", label: "Ada", kind: "oidc" }).value; + const b = issueSession({ sub: "user-2", label: "Grace", kind: "oidc" }).value; + + // The previous implementation produced one constant value for every login. + expect(a).not.toBe(b); + }); + + test("rejects a tampered payload", () => { + const { value } = issueSession({ sub: "user-1", label: "Ada", kind: "token" }); + const [payload, signature] = value.split("."); + const forged = Buffer.from(JSON.stringify({ v: 1, sub: "root", label: "root", kind: "token", iat: 0, exp: 9e9 }), "utf8").toString("base64url"); + + expect(readSession(`${forged}.${signature}`)).toBeNull(); + expect(payload).toBeTruthy(); + }); + + test("rejects a tampered signature", () => { + const { value } = issueSession({ sub: "user-1", label: "Ada", kind: "token" }); + const cut = value.lastIndexOf("."); + const flipped = value.slice(cut + 1, cut + 2) === "a" ? "b" : "a"; + + expect(readSession(`${value.slice(0, cut + 1)}${flipped}${value.slice(cut + 2)}`)).toBeNull(); + }); + + test("rejects malformed values", () => { + expect(readSession(undefined)).toBeNull(); + expect(readSession("")).toBeNull(); + expect(readSession("no-separator")).toBeNull(); + expect(readSession(".onlysig")).toBeNull(); + }); + + test("enforces expiry server-side", () => { + const now = Date.now(); + const { value } = issueSession({ sub: "user-1", label: "Ada", kind: "token", now }); + + expect(readSession(value, now + (sessionTtlSeconds - 5) * 1000)).not.toBeNull(); + expect(readSession(value, now + (sessionTtlSeconds + 5) * 1000)).toBeNull(); + }); +}); diff --git a/admin/tsconfig.json b/admin/tsconfig.json index 16640ee..da43bcf 100644 --- a/admin/tsconfig.json +++ b/admin/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "lib": ["ESNext", "DOM"], + "lib": ["ESNext", "DOM", "DOM.Iterable"], "target": "ESNext", "module": "ESNext", "moduleResolution": "bundler", diff --git a/docs-site/docs/en/admin.md b/docs-site/docs/en/admin.md index e540724..f1a1225 100644 --- a/docs-site/docs/en/admin.md +++ b/docs-site/docs/en/admin.md @@ -37,9 +37,31 @@ The Filegate bearer token stays on the admin server. Browser uploads and downloa |---|---:|---:|---| | `FILEGATE_URL` | Admin server process | Yes | REST API URL reachable from the admin app. | | `FILEGATE_TOKEN` | Admin server process | Yes | Filegate bearer token kept server-side. | -| `ADMIN_TOKEN` | Browser login | No | Separate admin login token. Defaults to `FILEGATE_TOKEN`. | -| `ADMIN_SESSION_SECRET` | Browser session cookie | No | Session signing secret. Defaults to `FILEGATE_TOKEN`. | +| `ADMIN_TOKEN` | Browser login | Yes | Admin login token. Must differ from `FILEGATE_TOKEN`. | +| `ADMIN_SESSION_SECRET` | Browser session cookie | No | Session signing secret. Generated at boot when unset; sessions then survive neither a restart nor a second replica. | | `PORT` | Admin server process | No | HTTP listen port. Defaults to `3000`. | +| `ADMIN_TRUST_PROXY` | Rate limiting | No | Set when a reverse proxy fronts the admin app, so `X-Forwarded-For` identifies the client instead of the socket address. | +| `ADMIN_COOKIE_SECURE` | Browser session cookie | No | `auto` (default), `true` or `false`. Auto sets `Secure` unless the request host is localhost. | +| `REDIS_URL` | Rate limiting | No | Shares the login rate limit across replicas. In-memory when unset. | + +`ADMIN_TOKEN` is required and must differ from `FILEGATE_TOKEN`. It previously +defaulted to it, which meant brute-forcing the admin login yielded the Filegate +master token; startup now refuses that configuration. + +## Login and sessions + +Sign-in issues a stateless signed session cookie holding subject, label and +expiry, verified server-side on every request. `POST /login` is rate limited to +10 attempts per 5 minutes per client. + +Behind a TLS-terminating ingress the admin process sees plain HTTP, so the +`Secure` cookie flag is driven by `ADMIN_COOKIE_SECURE` rather than by the +observed request protocol. The default marks the cookie `Secure` everywhere +except localhost. + +For shared rate limiting across replicas, set `REDIS_URL` in the process +environment before launch; the Redis connection is resolved at startup and not +re-read afterwards. ## Start the admin app From e9eb0918cb756843069e82a3375af46500861c62 Mon Sep 17 00:00:00 2001 From: valentinkolb Date: Sat, 25 Jul 2026 21:18:21 +0200 Subject: [PATCH 03/49] feat(admin): add OIDC single sign-on Authorization code flow with PKCE, enabled by setting OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET and OIDC_REDIRECT_URL together. Endpoints come from discovery, and the ID token is verified against the provider JWKS with issuer, audience and nonce checked. OIDC_ALLOWED_GROUPS is optional: providers like Authentik restrict access on the client itself, so requiring a second allowlist here would be duplicate bookkeeping. When unset, startup warns that access control is delegated to the provider. ADMIN_TOKEN becomes optional once OIDC is configured and stays available as a break-glass login. Dropping it hides the token form entirely. The flow state cookie is SameSite=Lax because the callback arrives as a cross-site navigation; Strict would drop it and fail every sign-in. Kept deliberately small: no refresh tokens, no userinfo call, no session store, no provider-side logout. Sessions last 12 hours, then re-login. Extracts the signing helpers into lib/signed.ts so the session and the OIDC flow state share one implementation. --- admin/README.md | 31 +++- admin/bun.lock | 3 + admin/bunfig.toml | 8 + admin/package.json | 3 +- admin/src/app.tsx | 23 ++- admin/src/components/Layout.tsx | 42 +++-- admin/src/lib/auth.ts | 67 +++++++- admin/src/lib/env.ts | 90 ++++++++++- admin/src/lib/oidc.ts | 225 ++++++++++++++++++++++++++ admin/src/lib/session.ts | 62 ++------ admin/src/lib/signed.ts | 51 ++++++ admin/src/styles.css | 1 + admin/test/fake-idp.ts | 128 +++++++++++++++ admin/test/oidc-delegated.test.ts | 108 +++++++++++++ admin/test/oidc.test.ts | 255 ++++++++++++++++++++++++++++++ docs-site/docs/en/admin.md | 38 ++++- 16 files changed, 1050 insertions(+), 85 deletions(-) create mode 100644 admin/src/lib/oidc.ts create mode 100644 admin/src/lib/signed.ts create mode 100644 admin/test/fake-idp.ts create mode 100644 admin/test/oidc-delegated.test.ts create mode 100644 admin/test/oidc.test.ts diff --git a/admin/README.md b/admin/README.md index 4f3861a..be9958b 100644 --- a/admin/README.md +++ b/admin/README.md @@ -25,7 +25,7 @@ Open `http://127.0.0.1:3000` and sign in with `ADMIN_TOKEN`. |---|---:|---| | `FILEGATE_URL` | yes | REST API base URL, reachable from the admin server. | | `FILEGATE_TOKEN` | yes | Filegate bearer token, kept server-side. | -| `ADMIN_TOKEN` | yes | Admin login token. Must differ from `FILEGATE_TOKEN`. | +| `ADMIN_TOKEN` | see note | Admin login token. Must differ from `FILEGATE_TOKEN`. Required unless OIDC is configured, where it stays useful as a break-glass login. | | `ADMIN_SESSION_SECRET` | no | Session signing secret. Generated at boot when unset, which means sessions survive neither a restart nor a second replica. Set it in production. | | `PORT` | no | Listen port, default `3000`. | | `ADMIN_TRUST_PROXY` | no | Set when a reverse proxy sits in front, so `X-Forwarded-For` is used for rate limiting instead of the socket address. | @@ -36,6 +36,35 @@ Open `http://127.0.0.1:3000` and sign in with `ADMIN_TOKEN`. the two are equal: sharing them means guessing the admin login hands out the Filegate master credential. `ADMIN_SESSION_SECRET` must likewise differ from both. +## Single sign-on (OIDC) + +Setting these four together enables OIDC; leave them unset for token-only login. + +| Variable | Required | Meaning | +|---|---:|---| +| `OIDC_ISSUER` | yes | Issuer URL. Discovery reads `/.well-known/openid-configuration`. Must be https outside localhost. | +| `OIDC_CLIENT_ID` | yes | Client id. | +| `OIDC_CLIENT_SECRET` | yes | Client secret; stays server-side. | +| `OIDC_REDIRECT_URL` | yes | Must match the client's redirect URI, ending in `/auth/callback`. | +| `OIDC_SCOPES` | no | Default `openid profile email`. `openid` is added if missing. | +| `OIDC_GROUPS_CLAIM` | no | Claim holding group membership, default `groups`. | +| `OIDC_ALLOWED_GROUPS` | no | Comma-separated allowlist. **When unset, anyone your provider lets through this client becomes an admin.** | + +`OIDC_ALLOWED_GROUPS` is deliberately optional: providers such as Authentik bind +a group policy to the application itself, so a second allowlist here would be +duplicate bookkeeping. Leaving it unset delegates access control to the provider +and logs a warning at startup saying so. + +Authorization code flow with PKCE. The ID token is verified against the +provider's JWKS, and issuer, audience and nonce are all checked. Sessions last 12 +hours and are not refreshed; there is no call to the provider after login. + +When both are configured the login page offers both, and `ADMIN_TOKEN` remains a +break-glass path. Drop `ADMIN_TOKEN` to make single sign-on the only way in; the +token form then disappears. + +Logout is local to the admin app and does not end the session at the provider. + ## Sessions and login Sign-in issues a stateless signed session cookie carrying subject, label and diff --git a/admin/bun.lock b/admin/bun.lock index 74623d8..2725963 100644 --- a/admin/bun.lock +++ b/admin/bun.lock @@ -10,6 +10,7 @@ "@valentinkolb/stdlib": "^0.16.0", "@valentinkolb/sync": "^5.6.0", "hono": "^4.12.25", + "jose": "^6.2.4", "solid-js": "^1.9.13", }, "devDependencies": { @@ -137,6 +138,8 @@ "html-entities": ["html-entities@2.3.3", "", {}, "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA=="], + "jose": ["jose@6.2.4", "", {}, "sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], diff --git a/admin/bunfig.toml b/admin/bunfig.toml index 786a377..ca551c9 100644 --- a/admin/bunfig.toml +++ b/admin/bunfig.toml @@ -1,2 +1,10 @@ [test] +# Registers Solid's JSX transform; without it every rendered page throws +# "React is not defined" under bun test. preload = ["./test/preload.ts"] + +# NOTE: run the suite with `bun run test`, not bare `bun test`. +# Each test file configures the admin differently (token-only, OIDC with an +# allowlist, OIDC-only) and the resolved environment is cached per process, so +# the files need one fresh global each. That is `--isolate`, which bunfig does +# not support, so it lives in the package.json test script. diff --git a/admin/package.json b/admin/package.json index eb4888d..07dba2c 100644 --- a/admin/package.json +++ b/admin/package.json @@ -8,7 +8,7 @@ "build": "bun run build:sdk && bun run src/build.ts", "build:sdk": "bunx tsc -p ../sdk/ts/tsconfig.json && rm -rf node_modules/@valentinkolb/filegate/dist && cp -R ../sdk/ts/dist node_modules/@valentinkolb/filegate/dist", "start": "bun dist/server.js", - "test": "bun test", + "test": "bun test --isolate", "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { @@ -17,6 +17,7 @@ "@valentinkolb/stdlib": "^0.16.0", "@valentinkolb/sync": "^5.6.0", "hono": "^4.12.25", + "jose": "^6.2.4", "solid-js": "^1.9.13" }, "devDependencies": { diff --git a/admin/src/app.tsx b/admin/src/app.tsx index 797b725..b0c55e3 100644 --- a/admin/src/app.tsx +++ b/admin/src/app.tsx @@ -17,7 +17,7 @@ import { type UploadSessionDirectRequest, } from "@valentinkolb/filegate"; import { Hono } from "hono"; -import { login, logout, requireAuth } from "./lib/auth"; +import { authMethods, login, logout, oidcBegin, oidcCallback, requireAuth } from "./lib/auth"; import { client, isList, parentPath, resolveDirectory } from "./lib/filegate"; import { env } from "./lib/env"; import { errorMessage, formatRetryAfter, redirectFiles, selectedFiles } from "./lib/format"; @@ -64,11 +64,14 @@ export const app = new Hono() "/login", ...ssr(async (c) => { setPage(c, "Sign in"); - const error = loginError(c.req.query("error"), c.req.query("retry")); - return () => ; + const error = loginError(c.req.query("error"), c.req.query("retry"), c.req.query("reason")); + const methods = authMethods(); + return () => ; }), ) .post("/login", login) + .get("/auth/login", oidcBegin) + .get("/auth/callback", oidcCallback) .use("*", requireAuth()) .post("/logout", logout) .get( @@ -208,12 +211,24 @@ export const app = new Hono() return c.redirect("/system?notice=rescan+started", 303); }); -function loginError(code: string | undefined, retry: string | undefined): string | undefined { +const oidcErrors: Record = { + state: "The sign-in attempt expired or was started elsewhere. Please try again.", + nonce: "The sign-in response did not match this attempt. Please try again.", + group: "Your account is not a member of a group allowed to use this admin.", + provider: "The identity provider declined the sign-in.", + discovery: "The identity provider could not be reached. Check the server logs.", + token: "The identity provider response could not be verified. Check the server logs.", +}; + +function loginError(code: string | undefined, retry: string | undefined, reason: string | undefined): string | undefined { if (code === "invalid") return "Invalid admin token"; if (code === "throttled") { const wait = formatRetryAfter(Number(retry)); return wait ? `Too many sign-in attempts. Try again in ${wait}.` : "Too many sign-in attempts. Try again later."; } + if (code === "oidc") { + return (reason && oidcErrors[reason]) || "Single sign-on failed. Check the server logs."; + } return undefined; } diff --git a/admin/src/components/Layout.tsx b/admin/src/components/Layout.tsx index 997655e..eb3debb 100644 --- a/admin/src/components/Layout.tsx +++ b/admin/src/components/Layout.tsx @@ -78,7 +78,7 @@ export function Layout(props: LayoutProps) { ); } -export function LoginPage(props: { error?: string }) { +export function LoginPage(props: { error?: string; methods: { token: boolean; oidc: boolean } }) { return (
@@ -87,22 +87,30 @@ export function LoginPage(props: { error?: string }) {
{props.error &&
{props.error}
} -
-
- - -
- -
+ {props.methods.oidc && ( + + )} + {props.methods.oidc && props.methods.token && } + {props.methods.token && ( +
+
+ + +
+ +
+ )}
diff --git a/admin/src/lib/auth.ts b/admin/src/lib/auth.ts index 9db043c..42f71f9 100644 --- a/admin/src/lib/auth.ts +++ b/admin/src/lib/auth.ts @@ -2,9 +2,11 @@ import { timingSafeEqual } from "node:crypto"; import type { Context, MiddlewareHandler } from "hono"; import { deleteCookie, getCookie, setCookie } from "hono/cookie"; import { env } from "./env"; +import { beginLogin, completeLogin, OidcError, oidcStateCookieName, oidcStateTtlSeconds, type OidcFlowState } from "./oidc"; import { recordLoginAttempt } from "./ratelimit"; import { clientId, useSecureCookie } from "./request"; import { issueSession, readSession, sessionCookieName, sessionTtlSeconds, type AdminSession } from "./session"; +import { seal, unseal } from "./signed"; /** Subject used for the shared-token login; OIDC logins carry the IdP subject. */ const tokenSubject = "local-admin"; @@ -16,6 +18,12 @@ function equal(a: string, b: string): boolean { return ab.length === bb.length && timingSafeEqual(ab, bb); } +/** Which sign-in options to offer on the login page. */ +export function authMethods(): { token: boolean; oidc: boolean } { + const cfg = env(); + return { token: !!cfg.adminToken, oidc: !!cfg.oidc }; +} + /** The verified session for this request, or null when unauthenticated. */ export function currentSession(c: Context): AdminSession | null { const existing = c.get("session"); @@ -48,6 +56,9 @@ function establish(c: Context, input: { sub: string; label: string; kind: AdminS } export async function login(c: Context): Promise { + const adminToken = env().adminToken; + if (!adminToken) return c.redirect("/login", 303); + // Count the attempt before checking the token, so a wrong guess costs budget. const attempt = await recordLoginAttempt(clientId(c)); if (attempt.limited) { @@ -57,7 +68,7 @@ export async function login(c: Context): Promise { const body = await c.req.parseBody(); const token = String(body.token || ""); - if (!equal(token, env().adminToken)) { + if (!equal(token, adminToken)) { return c.redirect("/login?error=invalid", 303); } @@ -69,3 +80,57 @@ export function logout(c: Context): Response { deleteCookie(c, sessionCookieName, { path: "/" }); return c.redirect("/login", 303); } + +function oidcFailure(c: Context, err: unknown): Response { + const reason = err instanceof OidcError ? err.reason : "unknown"; + console.error(`[filegate-admin] OIDC login failed (${reason}):`, err instanceof Error ? err.message : err); + return c.redirect(`/login?error=oidc&reason=${encodeURIComponent(reason)}`, 303); +} + +export async function oidcBegin(c: Context): Promise { + if (!env().oidc) return c.redirect("/login", 303); + + try { + const { redirectTo, flow } = await beginLogin(); + setCookie(c, oidcStateCookieName, seal(flow, oidcStateTtlSeconds), { + httpOnly: true, + // Lax, not Strict: the callback arrives as a top-level navigation from the + // identity provider, and a Strict cookie would not be sent with it. + sameSite: "Lax", + secure: useSecureCookie(c), + path: "/", + maxAge: oidcStateTtlSeconds, + }); + return c.redirect(redirectTo, 303); + } catch (err) { + return oidcFailure(c, err); + } +} + +export async function oidcCallback(c: Context): Promise { + if (!env().oidc) return c.redirect("/login", 303); + + const clearState = () => deleteCookie(c, oidcStateCookieName, { path: "/" }); + + try { + // The provider reports user-facing failures such as a denied consent here. + const providerError = c.req.query("error"); + if (providerError) throw new OidcError("provider", `identity provider returned ${providerError}`); + + const flow = unseal(getCookie(c, oidcStateCookieName)); + if (!flow) throw new OidcError("state", "OIDC login state is missing or expired; start the login again"); + + const code = c.req.query("code"); + if (!code) throw new OidcError("state", "callback did not include an authorization code"); + + const identity = await completeLogin({ code, state: c.req.query("state") ?? "", flow }); + + clearState(); + establish(c, { sub: identity.sub, label: identity.label, kind: "oidc" }); + console.log(`[filegate-admin] OIDC login: ${identity.label} (${identity.sub})`); + return c.redirect("/", 303); + } catch (err) { + clearState(); + return oidcFailure(c, err); + } +} diff --git a/admin/src/lib/env.ts b/admin/src/lib/env.ts index 30ff434..0db8b99 100644 --- a/admin/src/lib/env.ts +++ b/admin/src/lib/env.ts @@ -2,15 +2,28 @@ import { randomBytes } from "node:crypto"; export type CookieSecureMode = "auto" | "always" | "never"; +export type OidcSettings = { + issuer: string; + clientId: string; + clientSecret: string; + redirectUrl: string; + scopes: string; + groupsClaim: string; + /** Empty means access control is delegated to the identity provider. */ + allowedGroups: string[]; +}; + export type AdminEnv = { filegateUrl: string; filegateToken: string; - adminToken: string; + /** Undefined when only OIDC login is configured; the token form is then hidden. */ + adminToken?: string; sessionSecret: string; port: number; trustProxy: boolean; cookieSecure: CookieSecureMode; redisUrl?: string; + oidc?: OidcSettings; }; function required(name: string, hint: string): string { @@ -45,32 +58,99 @@ function resolveSessionSecret(): string { return randomBytes(32).toString("hex"); } +function list(name: string): string[] { + return (Bun.env[name] ?? "") + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean); +} + +/** + * OIDC is enabled by configuring it. All four core values must be present + * together, so a half-filled configuration fails loudly instead of silently + * falling back to token-only login. + */ +function resolveOidc(): OidcSettings | undefined { + const core = { + issuer: Bun.env.OIDC_ISSUER?.trim(), + clientId: Bun.env.OIDC_CLIENT_ID?.trim(), + clientSecret: Bun.env.OIDC_CLIENT_SECRET?.trim(), + redirectUrl: Bun.env.OIDC_REDIRECT_URL?.trim(), + }; + + const provided = Object.entries(core).filter(([, value]) => !!value); + if (provided.length === 0) return undefined; + if (provided.length < 4) { + const missing = Object.entries(core) + .filter(([, value]) => !value) + .map(([key]) => `OIDC_${key.replace(/[A-Z]/g, (c) => `_${c}`).toUpperCase()}`); + throw new Error(`incomplete OIDC configuration; missing ${missing.join(", ")}`); + } + + let issuerUrl: URL; + try { + issuerUrl = new URL(core.issuer!); + } catch { + throw new Error(`OIDC_ISSUER must be an absolute URL, got ${core.issuer}`); + } + if (issuerUrl.protocol !== "https:" && issuerUrl.hostname !== "localhost" && issuerUrl.hostname !== "127.0.0.1") { + throw new Error("OIDC_ISSUER must use https outside of localhost"); + } + + const scopes = Bun.env.OIDC_SCOPES?.trim() || "openid profile email"; + return { + issuer: core.issuer!, + clientId: core.clientId!, + clientSecret: core.clientSecret!, + redirectUrl: core.redirectUrl!, + // openid is not optional; add it back rather than failing over a typo. + scopes: scopes.split(/\s+/).includes("openid") ? scopes : `openid ${scopes}`, + groupsClaim: Bun.env.OIDC_GROUPS_CLAIM?.trim() || "groups", + allowedGroups: list("OIDC_ALLOWED_GROUPS"), + }; +} + function resolve(): AdminEnv { const port = Number(Bun.env.PORT || 3000); if (!Number.isInteger(port) || port <= 0 || port > 65535) { throw new Error(`PORT must be a valid port number, got ${Bun.env.PORT}`); } + const oidc = resolveOidc(); const cfg: AdminEnv = { filegateUrl: required("FILEGATE_URL", "REST API base URL of the Filegate server"), filegateToken: required("FILEGATE_TOKEN", "Filegate bearer token, kept server-side"), // Deliberately no fallback to FILEGATE_TOKEN. Sharing them means brute - // forcing the admin login yields the Filegate master credential. - adminToken: required("ADMIN_TOKEN", "admin login token; must differ from FILEGATE_TOKEN"), + // forcing the admin login yields the Filegate master credential. Optional + // once OIDC can log people in, where it stays useful as a break-glass path. + adminToken: oidc + ? Bun.env.ADMIN_TOKEN?.trim() || undefined + : required("ADMIN_TOKEN", "admin login token; must differ from FILEGATE_TOKEN, or configure OIDC instead"), sessionSecret: resolveSessionSecret(), port, trustProxy: boolFlag("ADMIN_TRUST_PROXY"), cookieSecure: cookieSecureMode(), redisUrl: Bun.env.REDIS_URL?.trim() || undefined, + oidc, }; - if (cfg.adminToken === cfg.filegateToken) { + if (cfg.adminToken && cfg.adminToken === cfg.filegateToken) { throw new Error("ADMIN_TOKEN must differ from FILEGATE_TOKEN so the admin login cannot leak the Filegate master token"); } - if (cfg.sessionSecret === cfg.filegateToken || cfg.sessionSecret === cfg.adminToken) { + if (cfg.sessionSecret === cfg.filegateToken || (cfg.adminToken && cfg.sessionSecret === cfg.adminToken)) { throw new Error("ADMIN_SESSION_SECRET must differ from FILEGATE_TOKEN and ADMIN_TOKEN"); } + if (oidc && oidc.allowedGroups.length === 0) { + // Not an error: identity providers such as Authentik bind a group policy to + // the client itself, which makes a second allowlist here duplicate + // bookkeeping. It is loud because the failure mode of an open IdP in front + // of an unrestricted admin panel is severe and silent. + console.warn( + "[filegate-admin] OIDC_ALLOWED_GROUPS is not set: any account your identity provider lets through this client becomes an admin. Access control is delegated to the IdP.", + ); + } + return cfg; } diff --git a/admin/src/lib/oidc.ts b/admin/src/lib/oidc.ts new file mode 100644 index 0000000..104b8e9 --- /dev/null +++ b/admin/src/lib/oidc.ts @@ -0,0 +1,225 @@ +import { createHash, randomBytes } from "node:crypto"; +import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose"; +import { env } from "./env"; + +export const oidcStateCookieName = "filegate_admin_oidc"; +export const oidcStateTtlSeconds = 10 * 60; + +/** Short-lived per-attempt values that must survive the round trip to the IdP. */ +export type OidcFlowState = { + state: string; + nonce: string; + verifier: string; +}; + +export type OidcIdentity = { + sub: string; + label: string; + groups: string[]; +}; + +/** Carries a stable reason so the login page can say something useful. */ +export class OidcError extends Error { + constructor( + readonly reason: string, + message: string, + ) { + super(message); + this.name = "OidcError"; + } +} + +type Discovery = { + issuer: string; + authorization_endpoint: string; + token_endpoint: string; + jwks_uri: string; + token_endpoint_auth_methods_supported?: string[]; +}; + +// Discovery and the JWKS are cached for the process lifetime. A failed discovery +// clears the cache so the next attempt retries instead of poisoning every login +// until restart. +let discoveryCache: Promise | undefined; +const jwksCache = new Map>(); + +async function discover(): Promise { + discoveryCache ??= (async () => { + const { issuer } = oidcRequired(); + const url = `${issuer.replace(/\/+$/, "")}/.well-known/openid-configuration`; + + const res = await fetch(url, { headers: { accept: "application/json" } }); + if (!res.ok) throw new OidcError("discovery", `OIDC discovery failed: ${url} returned ${res.status}`); + + const doc = (await res.json()) as Partial; + for (const field of ["issuer", "authorization_endpoint", "token_endpoint", "jwks_uri"] as const) { + if (typeof doc[field] !== "string" || !doc[field]) { + throw new OidcError("discovery", `OIDC discovery document from ${url} is missing ${field}`); + } + } + return doc as Discovery; + })().catch((err) => { + discoveryCache = undefined; + throw err; + }); + + return discoveryCache; +} + +function keys(jwksUri: string) { + let set = jwksCache.get(jwksUri); + if (!set) { + set = createRemoteJWKSet(new URL(jwksUri)); + jwksCache.set(jwksUri, set); + } + return set; +} + +function oidcRequired() { + const settings = env().oidc; + if (!settings) throw new OidcError("disabled", "OIDC is not configured"); + return settings; +} + +function base64url(input: Buffer): string { + return input.toString("base64url"); +} + +/** + * Start a login. The returned flow state must be sealed into a cookie by the + * caller; nothing is kept server-side, so this works across replicas unchanged. + */ +export async function beginLogin(): Promise<{ redirectTo: string; flow: OidcFlowState }> { + const settings = oidcRequired(); + const discovery = await discover(); + + const flow: OidcFlowState = { + state: base64url(randomBytes(24)), + nonce: base64url(randomBytes(24)), + verifier: base64url(randomBytes(32)), + }; + + const params = new URLSearchParams({ + response_type: "code", + client_id: settings.clientId, + redirect_uri: settings.redirectUrl, + scope: settings.scopes, + state: flow.state, + nonce: flow.nonce, + code_challenge: base64url(createHash("sha256").update(flow.verifier).digest()), + code_challenge_method: "S256", + }); + + return { redirectTo: `${discovery.authorization_endpoint}?${params}`, flow }; +} + +/** + * Finish a login: verify the round trip, exchange the code, validate the ID + * token and map its claims onto an identity. + */ +export async function completeLogin(input: { code: string; state: string; flow: OidcFlowState }): Promise { + const settings = oidcRequired(); + const discovery = await discover(); + + if (!input.state || input.state !== input.flow.state) { + throw new OidcError("state", "OIDC state mismatch; the login was not started by this browser"); + } + + const idToken = await exchangeCode(discovery, input.code, input.flow.verifier); + const claims = await verifyIdToken(discovery, idToken, input.flow.nonce); + const identity = toIdentity(claims, settings.groupsClaim); + + if (settings.allowedGroups.length > 0) { + const allowed = identity.groups.some((group) => settings.allowedGroups.includes(group)); + if (!allowed) { + throw new OidcError("group", `${identity.label} is not in an allowed group`); + } + } + + return identity; +} + +async function exchangeCode(discovery: Discovery, code: string, verifier: string): Promise { + const settings = oidcRequired(); + const body = new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: settings.redirectUrl, + code_verifier: verifier, + }); + const headers: Record = { "content-type": "application/x-www-form-urlencoded", accept: "application/json" }; + + // Prefer basic auth, which is the spec default, but fall back to form-encoded + // credentials when the provider only advertises that. + const supported = discovery.token_endpoint_auth_methods_supported; + const usePost = Array.isArray(supported) && !supported.includes("client_secret_basic") && supported.includes("client_secret_post"); + if (usePost) { + body.set("client_id", settings.clientId); + body.set("client_secret", settings.clientSecret); + } else { + const credentials = `${encodeURIComponent(settings.clientId)}:${encodeURIComponent(settings.clientSecret)}`; + headers.authorization = `Basic ${Buffer.from(credentials, "utf8").toString("base64")}`; + } + + const res = await fetch(discovery.token_endpoint, { method: "POST", headers, body }); + if (!res.ok) { + const detail = await res.text().catch(() => ""); + throw new OidcError("token", `token exchange failed with ${res.status}: ${detail.slice(0, 200)}`); + } + + const payload = (await res.json()) as { id_token?: unknown }; + if (typeof payload.id_token !== "string" || !payload.id_token) { + throw new OidcError("token", "token response did not contain an id_token"); + } + return payload.id_token; +} + +async function verifyIdToken(discovery: Discovery, idToken: string, nonce: string): Promise { + const settings = oidcRequired(); + + // The spec would allow skipping signature verification here, since the token + // arrives directly from the token endpoint over TLS. We verify anyway: this is + // the gate to an admin panel, and jose makes it a few lines. + let payload: JWTPayload; + try { + ({ payload } = await jwtVerify(idToken, keys(discovery.jwks_uri), { + issuer: discovery.issuer, + audience: settings.clientId, + })); + } catch (err) { + throw new OidcError("token", `id_token verification failed: ${err instanceof Error ? err.message : "unknown error"}`); + } + + if (payload.nonce !== nonce) { + throw new OidcError("nonce", "id_token nonce mismatch; the response does not belong to this login attempt"); + } + if (typeof payload.sub !== "string" || !payload.sub) { + throw new OidcError("token", "id_token has no subject"); + } + + return payload; +} + +function firstString(...values: unknown[]): string | undefined { + for (const value of values) { + if (typeof value === "string" && value.trim()) return value.trim(); + } + return undefined; +} + +/** Providers disagree on group claim shape: an array, or one delimited string. */ +function readGroups(claims: JWTPayload, claim: string): string[] { + const raw = claims[claim]; + if (Array.isArray(raw)) return raw.filter((entry): entry is string => typeof entry === "string" && entry.trim() !== ""); + if (typeof raw === "string") return raw.split(/[,\s]+/).filter(Boolean); + return []; +} + +function toIdentity(claims: JWTPayload, groupsClaim: string): OidcIdentity { + const sub = claims.sub as string; + return { + sub, + label: firstString(claims.email, claims.preferred_username, claims.name) ?? sub, + groups: readGroups(claims, groupsClaim), + }; +} diff --git a/admin/src/lib/session.ts b/admin/src/lib/session.ts index 1bb5b5f..70d08c8 100644 --- a/admin/src/lib/session.ts +++ b/admin/src/lib/session.ts @@ -1,5 +1,4 @@ -import { createHmac, timingSafeEqual } from "node:crypto"; -import { env } from "./env"; +import { seal, unseal } from "./signed"; export const sessionCookieName = "filegate_admin"; export const sessionTtlSeconds = 12 * 60 * 60; @@ -13,7 +12,6 @@ export type AdminSession = { /** Human-readable label used in the UI and, later, in Filegate audit entries. */ label: string; kind: SessionKind; - issuedAt: number; expiresAt: number; }; @@ -28,76 +26,36 @@ type SessionPayload = { sub: string; label: string; kind: SessionKind; - iat: number; - exp: number; }; -function sign(payload: string): string { - return createHmac("sha256", env().sessionSecret).update(payload).digest("base64url"); -} - -function equal(a: string, b: string): boolean { - const ab = Buffer.from(a); - const bb = Buffer.from(b); - return ab.length === bb.length && timingSafeEqual(ab, bb); -} - /** * Issue a stateless signed session. The payload travels in the cookie so there - * is no session store to run; the signature is what makes it trustworthy, and - * expiresAt is verified server-side on every request rather than being left to - * the browser's cookie expiry. + * is no session store to run; expiry is enforced server-side on every request. */ export function issueSession(input: { sub: string; label: string; kind: SessionKind; now?: number }): { value: string; session: AdminSession; } { - const now = input.now ?? Date.now(); - const payload: SessionPayload = { - v: 1, - sub: input.sub, - label: input.label, - kind: input.kind, - iat: Math.floor(now / 1000), - exp: Math.floor(now / 1000) + sessionTtlSeconds, - }; - const encoded = Buffer.from(JSON.stringify(payload), "utf8").toString("base64url"); - return { - value: `${encoded}.${sign(encoded)}`, - session: toSession(payload), - }; + const payload: SessionPayload = { v: 1, sub: input.sub, label: input.label, kind: input.kind }; + const value = seal(payload, sessionTtlSeconds, input.now); + const session = readSession(value, input.now); + if (!session) throw new Error("issued session failed verification"); + return { value, session }; } /** Verify signature, shape and expiry. Returns null for anything untrustworthy. */ export function readSession(value: string | undefined, now = Date.now()): AdminSession | null { - if (!value) return null; - const cut = value.lastIndexOf("."); - if (cut <= 0) return null; - - const encoded = value.slice(0, cut); - if (!equal(value.slice(cut + 1), sign(encoded))) return null; - - let payload: SessionPayload; - try { - payload = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")); - } catch { - return null; - } + const payload = unseal(value, now); + if (!payload) return null; - if (payload?.v !== 1) return null; + if (payload.v !== 1) return null; if (typeof payload.sub !== "string" || !payload.sub) return null; if (payload.kind !== "token" && payload.kind !== "oidc") return null; - if (typeof payload.exp !== "number" || payload.exp * 1000 <= now) return null; - - return toSession(payload); -} -function toSession(payload: SessionPayload): AdminSession { return { sub: payload.sub, label: typeof payload.label === "string" && payload.label ? payload.label : payload.sub, kind: payload.kind, - issuedAt: payload.iat * 1000, expiresAt: payload.exp * 1000, }; } diff --git a/admin/src/lib/signed.ts b/admin/src/lib/signed.ts new file mode 100644 index 0000000..ed03629 --- /dev/null +++ b/admin/src/lib/signed.ts @@ -0,0 +1,51 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; +import { env } from "./env"; + +/** + * Signed, self-contained cookie values. + * + * Both the admin session and the short-lived OIDC flow state travel in cookies + * rather than in server memory, so nothing has to be shared between replicas and + * there is no store to expire. The signature is what makes them trustworthy and + * `exp` is always checked here, never left to the browser's cookie expiry. + */ + +type Envelope = { exp: number }; + +function signature(payload: string): string { + return createHmac("sha256", env().sessionSecret).update(payload).digest("base64url"); +} + +function equal(a: string, b: string): boolean { + const ab = Buffer.from(a); + const bb = Buffer.from(b); + return ab.length === bb.length && timingSafeEqual(ab, bb); +} + +/** Wrap a payload with an expiry and sign it. */ +export function seal(payload: T, ttlSeconds: number, now = Date.now()): string { + const body: T & Envelope = { ...payload, exp: Math.floor(now / 1000) + ttlSeconds }; + const encoded = Buffer.from(JSON.stringify(body), "utf8").toString("base64url"); + return `${encoded}.${signature(encoded)}`; +} + +/** Verify signature and expiry. Returns null for anything untrustworthy. */ +export function unseal(value: string | undefined, now = Date.now()): (T & Envelope) | null { + if (!value) return null; + + const cut = value.lastIndexOf("."); + if (cut <= 0) return null; + + const encoded = value.slice(0, cut); + if (!equal(value.slice(cut + 1), signature(encoded))) return null; + + let payload: T & Envelope; + try { + payload = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")); + } catch { + return null; + } + + if (typeof payload?.exp !== "number" || payload.exp * 1000 <= now) return null; + return payload; +} diff --git a/admin/src/styles.css b/admin/src/styles.css index 6b6246a..5ba4630 100644 --- a/admin/src/styles.css +++ b/admin/src/styles.css @@ -47,6 +47,7 @@ td:first-child{width:100%}th.num,td.num{text-align:right;font-variant-numeric:ta .empty{display:flex;flex-direction:column;gap:4px;align-items:flex-start;padding:26px 4px;color:var(--muted)}.empty strong{color:var(--ink);font-size:14px;font-weight:650}.empty span{font-size:13px}.detail-empty{padding:28px 16px} .notice{border:1px solid #b6d7a8;background:#eff8ec;color:#1f5132;border-radius:var(--radius);padding:10px 14px;margin-bottom:16px}.error{border:1px solid #f1b4a8;background:#fff3f1;color:#7a2417;border-radius:var(--radius);padding:10px 14px;margin-bottom:12px}.error-row{padding-top:14px;padding-bottom:0} .login{max-width:400px;margin:12vh auto} +.login-sso{width:100%}.login-divider{display:flex;align-items:center;gap:10px;margin:14px 0;color:var(--muted);font-size:12px}.login-divider::before,.login-divider::after{content:"";flex:1;height:1px;background:var(--line)} html.has-prompt,html.has-prompt body{overflow:hidden}.prompt{width:min(94vw,440px);max-height:min(86vh,720px);margin:auto;padding:0;border:0;border-radius:8px;background:transparent;color:var(--ink)}.prompt::backdrop{background:rgba(16,25,40,.42);backdrop-filter:blur(2px)}.prompt-panel{display:grid;max-height:min(86vh,720px);overflow:hidden;border:1px solid var(--line);border-radius:8px;background:#fff;box-shadow:0 18px 48px rgba(16,25,40,.22)}.prompt-head{display:flex;align-items:center;gap:12px;padding:16px 18px 12px;border-bottom:1px solid var(--line)}.prompt-head h2{font-size:16px}.prompt-close{appearance:none;margin-left:auto;border:0;background:transparent;color:var(--muted);font-size:22px;line-height:1;cursor:pointer}.prompt-close:hover{color:var(--ink)}.prompt-body{display:grid;gap:14px;min-width:0;overflow:auto;padding:16px 18px}.prompt-message{display:grid;gap:9px;margin:0;padding:10px 12px;border:1px solid #b7d9f8;border-radius:var(--radius);background:#f1f8ff;color:#264866;overflow-wrap:anywhere;white-space:pre-line}.prompt-badge{display:inline-flex;width:max-content;max-width:100%;align-items:center;padding:2px 7px;border:1px solid #9ecbf4;border-radius:999px;background:#dcefff;color:#193d5d;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:1.5;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.prompt-message-text{display:block}.prompt-form{display:grid;gap:12px}.prompt-form .field span{color:var(--muted);font-size:12px}.prompt-footer{display:flex;justify-content:flex-end;gap:8px;padding:12px 18px 16px;border-top:1px solid var(--line);background:#fafbfc} .uploads{position:fixed;right:20px;bottom:20px;width:400px;max-width:calc(100vw - 40px);background:#fff;border:1px solid var(--line);border-radius:8px;box-shadow:0 10px 34px rgba(16,25,40,.18);z-index:60;overflow:hidden} .uploads-head{display:flex;justify-content:space-between;align-items:center;gap:10px;padding:13px 16px;font-weight:650;font-size:13px} diff --git a/admin/test/fake-idp.ts b/admin/test/fake-idp.ts new file mode 100644 index 0000000..8cc0256 --- /dev/null +++ b/admin/test/fake-idp.ts @@ -0,0 +1,128 @@ +import { exportJWK, generateKeyPair, SignJWT } from "jose"; + +/** + * Minimal OpenID provider for tests: discovery, JWKS and a token endpoint. + * + * The token endpoint mints whatever the test asks for, because the desired + * claims are encoded into the authorization code. That keeps the real code path + * intact -- discovery, code exchange, JWKS signature verification, nonce and + * group checks all run for real -- while letting a test produce a wrong nonce or + * a token signed by an unknown key. + */ +export type FakeClaims = { + sub?: string; + email?: string; + name?: string; + groups?: string[] | string; + nonce?: string; + /** Sign with a key that is not in the published JWKS. */ + signWithUnknownKey?: boolean; + /** Override the audience to something other than the client id. */ + audience?: string; + /** Return a token response with no id_token at all. */ + omitIdToken?: boolean; +}; + +export function encodeClaims(claims: FakeClaims): string { + return Buffer.from(JSON.stringify(claims), "utf8").toString("base64url"); +} + +export type FakeIdp = { + issuer: string; + clientId: string; + clientSecret: string; + /** Auth methods advertised in discovery; defaults to basic only. */ + tokenRequests: { authorization?: string; body: Record }[]; + stop(): void; +}; + +export async function startFakeIdp(options: { authMethods?: string[]; port?: number; user?: FakeClaims } = {}): Promise { + const published = await generateKeyPair("RS256"); + const unknown = await generateKeyPair("RS256"); + const kid = "test-key-1"; + const jwk = { ...(await exportJWK(published.publicKey)), kid, alg: "RS256", use: "sig" }; + + const clientId = "filegate-admin-test"; + const clientSecret = "client-secret"; + const tokenRequests: FakeIdp["tokenRequests"] = []; + + const server = Bun.serve({ + port: options.port ?? 0, + fetch: async (req) => { + const url = new URL(req.url); + const issuer = `http://127.0.0.1:${server.port}`; + + if (url.pathname === "/.well-known/openid-configuration") { + return Response.json({ + issuer, + authorization_endpoint: `${issuer}/authorize`, + token_endpoint: `${issuer}/token`, + jwks_uri: `${issuer}/jwks`, + token_endpoint_auth_methods_supported: options.authMethods ?? ["client_secret_basic"], + }); + } + + if (url.pathname === "/jwks") { + return Response.json({ keys: [jwk] }); + } + + // Consent-free authorization endpoint: approves immediately and encodes + // the claims to mint into the authorization code. Used for driving the + // whole flow through a real browser; the tests build the code themselves. + if (url.pathname === "/authorize") { + const redirectUri = url.searchParams.get("redirect_uri"); + if (!redirectUri) return new Response("missing redirect_uri", { status: 400 }); + + const code = encodeClaims({ + nonce: url.searchParams.get("nonce") ?? undefined, + ...(options.user ?? { sub: "dev-user", email: "dev@example.com", groups: ["filegate-admins"] }), + }); + const back = new URL(redirectUri); + back.searchParams.set("code", code); + back.searchParams.set("state", url.searchParams.get("state") ?? ""); + return Response.redirect(back.toString(), 302); + } + + if (url.pathname === "/token" && req.method === "POST") { + const body = Object.fromEntries(new URLSearchParams(await req.text())); + tokenRequests.push({ authorization: req.headers.get("authorization") ?? undefined, body }); + + let claims: FakeClaims; + try { + claims = JSON.parse(Buffer.from(body.code ?? "", "base64url").toString("utf8")); + } catch { + return Response.json({ error: "invalid_grant" }, { status: 400 }); + } + + if (claims.omitIdToken) return Response.json({ access_token: "irrelevant", token_type: "Bearer" }); + + const { sub = "user-1", email, name, groups, nonce, audience, signWithUnknownKey } = claims; + const payload: Record = { nonce }; + if (email) payload.email = email; + if (name) payload.name = name; + if (groups !== undefined) payload.groups = groups; + + const idToken = await new SignJWT(payload) + .setProtectedHeader({ alg: "RS256", kid }) + .setIssuedAt() + .setIssuer(issuer) + .setSubject(sub) + .setAudience(audience ?? clientId) + .setExpirationTime("5m") + .sign(signWithUnknownKey ? unknown.privateKey : published.privateKey); + + return Response.json({ id_token: idToken, access_token: "irrelevant", token_type: "Bearer" }); + } + + return new Response("not found", { status: 404 }); + }, + }); + + return { + issuer: `http://127.0.0.1:${server.port}`, + clientId, + clientSecret, + tokenRequests, + stop: () => server.stop(true), + }; +} diff --git a/admin/test/oidc-delegated.test.ts b/admin/test/oidc-delegated.test.ts new file mode 100644 index 0000000..510097d --- /dev/null +++ b/admin/test/oidc-delegated.test.ts @@ -0,0 +1,108 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import type { Hono } from "hono"; +import { encodeClaims, startFakeIdp, type FakeIdp } from "./fake-idp"; + +/** + * The other OIDC configuration shape: no group allowlist and no ADMIN_TOKEN. + * Access control is delegated entirely to the identity provider, and single + * sign-on is the only way in. Separate file because the resolved environment is + * cached per process. + */ +let idp: FakeIdp; +let app: Hono; +let unseal: typeof import("../src/lib/signed").unseal; +let oidcStateCookieName: string; + +function get(path: string, cookie?: string): Promise { + return app.fetch( + new Request(`http://localhost${path}`, { + headers: { host: "localhost", ...(cookie ? { cookie } : {}) }, + redirect: "manual", + }), + ); +} + +function cookieValue(res: Response, name: string): string | undefined { + for (const entry of res.headers.getSetCookie()) { + const [pair] = entry.split(";"); + const [key, ...rest] = (pair ?? "").split("="); + if (key === name) return rest.join("="); + } + return undefined; +} + +async function loginAs(groups: string[] | undefined): Promise { + const begin = await get("/auth/login"); + const raw = cookieValue(begin, oidcStateCookieName); + const flow = unseal<{ state: string; nonce: string }>(raw); + if (!flow) throw new Error("no OIDC flow state cookie was set"); + + const code = encodeClaims({ nonce: flow.nonce, email: "someone@example.com", groups }); + return get(`/auth/callback?code=${code}&state=${encodeURIComponent(flow.state)}`, `${oidcStateCookieName}=${raw}`); +} + +beforeAll(async () => { + // Advertise only client_secret_post so the auth-method fallback is exercised. + idp = await startFakeIdp({ authMethods: ["client_secret_post"] }); + + Bun.env.FILEGATE_URL = "http://127.0.0.1:65535"; + Bun.env.FILEGATE_TOKEN = "filegate-token"; + Bun.env.ADMIN_SESSION_SECRET = "session-secret"; + Bun.env.OIDC_ISSUER = idp.issuer; + Bun.env.OIDC_CLIENT_ID = idp.clientId; + Bun.env.OIDC_CLIENT_SECRET = idp.clientSecret; + Bun.env.OIDC_REDIRECT_URL = "http://localhost/auth/callback"; + delete Bun.env.ADMIN_TOKEN; + delete Bun.env.OIDC_ALLOWED_GROUPS; + delete Bun.env.REDIS_URL; + + app = (await import("../src/app")).app; + unseal = (await import("../src/lib/signed")).unseal; + oidcStateCookieName = (await import("../src/lib/oidc")).oidcStateCookieName; +}); + +afterAll(() => idp.stop()); + +describe("delegated access control", () => { + test("admits any account when no allowlist is configured", async () => { + // This is the documented behaviour: providers like Authentik restrict access + // on the client itself, so a second allowlist here would be duplicate work. + expect((await loginAs(["some-unrelated-group"])).headers.get("location")).toBe("/"); + expect((await loginAs(undefined)).headers.get("location")).toBe("/"); + }); +}); + +describe("single sign-on only", () => { + test("the login page offers no token form without ADMIN_TOKEN", async () => { + const body = await (await get("/login")).text(); + + expect(body).toContain("/auth/login"); + expect(body).not.toContain('name="token"'); + }); + + test("posting to the token login is refused", async () => { + const res = await app.fetch( + new Request("http://localhost/login", { + method: "POST", + body: new URLSearchParams({ token: "anything" }), + headers: { "content-type": "application/x-www-form-urlencoded", host: "localhost" }, + redirect: "manual", + }), + ); + + expect(res.headers.get("location")).toBe("/login"); + expect(cookieValue(res, "filegate_admin")).toBeUndefined(); + }); +}); + +describe("token endpoint auth method", () => { + test("falls back to form credentials when basic auth is not advertised", async () => { + idp.tokenRequests.length = 0; + await loginAs(["any"]); + + const request = idp.tokenRequests.at(-1); + expect(request?.authorization).toBeUndefined(); + expect(request?.body.client_id).toBe(idp.clientId); + expect(request?.body.client_secret).toBe(idp.clientSecret); + }); +}); diff --git a/admin/test/oidc.test.ts b/admin/test/oidc.test.ts new file mode 100644 index 0000000..7eb727b --- /dev/null +++ b/admin/test/oidc.test.ts @@ -0,0 +1,255 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import type { Hono } from "hono"; +import { encodeClaims, startFakeIdp, type FakeClaims, type FakeIdp } from "./fake-idp"; + +const adminToken = "admin-token"; +let idp: FakeIdp; +let app: Hono; +let unseal: typeof import("../src/lib/signed").unseal; +let oidcStateCookieName: string; + +function get(path: string, cookie?: string): Promise { + return app.fetch( + new Request(`http://localhost${path}`, { + headers: { host: "localhost", ...(cookie ? { cookie } : {}) }, + redirect: "manual", + }), + ); +} + +function cookieValue(res: Response, name: string): string | undefined { + for (const entry of res.headers.getSetCookie()) { + const [pair] = entry.split(";"); + const [key, ...rest] = (pair ?? "").split("="); + if (key === name) return rest.join("="); + } + return undefined; +} + +/** Runs the redirect to the provider and returns the flow state the app sealed. */ +async function beginFlow() { + const res = await get("/auth/login"); + const raw = cookieValue(res, oidcStateCookieName); + const flow = unseal<{ state: string; nonce: string; verifier: string }>(raw); + if (!flow) throw new Error("no OIDC flow state cookie was set"); + return { res, flow, cookie: `${oidcStateCookieName}=${raw}` }; +} + +/** Completes a flow with claims of our choosing, defaulting to a valid login. */ +async function callback(overrides: FakeClaims & { state?: string } = {}) { + const { flow, cookie } = await beginFlow(); + const { state, ...claims } = overrides; + const code = encodeClaims({ nonce: flow.nonce, email: "ada@example.com", groups: ["filegate-admins"], ...claims }); + return get(`/auth/callback?code=${code}&state=${encodeURIComponent(state ?? flow.state)}`, cookie); +} + +function reasonOf(res: Response): string { + return new URL(res.headers.get("location") ?? "", "http://localhost").searchParams.get("reason") ?? ""; +} + +beforeAll(async () => { + idp = await startFakeIdp(); + + Bun.env.FILEGATE_URL = "http://127.0.0.1:65535"; + Bun.env.FILEGATE_TOKEN = "filegate-token"; + Bun.env.ADMIN_TOKEN = adminToken; + Bun.env.ADMIN_SESSION_SECRET = "session-secret"; + Bun.env.OIDC_ISSUER = idp.issuer; + Bun.env.OIDC_CLIENT_ID = idp.clientId; + Bun.env.OIDC_CLIENT_SECRET = idp.clientSecret; + Bun.env.OIDC_REDIRECT_URL = "http://localhost/auth/callback"; + Bun.env.OIDC_ALLOWED_GROUPS = "filegate-admins"; + delete Bun.env.REDIS_URL; + + app = (await import("../src/app")).app; + const signed = await import("../src/lib/signed"); + unseal = signed.unseal; + oidcStateCookieName = (await import("../src/lib/oidc")).oidcStateCookieName; +}); + +afterAll(() => idp.stop()); + +describe("authorization request", () => { + test("redirects to the provider with PKCE and a nonce", async () => { + const { res, flow } = await beginFlow(); + const target = new URL(res.headers.get("location") ?? ""); + + expect(res.status).toBe(303); + expect(target.origin).toBe(idp.issuer); + expect(target.pathname).toBe("/authorize"); + expect(target.searchParams.get("response_type")).toBe("code"); + expect(target.searchParams.get("client_id")).toBe(idp.clientId); + expect(target.searchParams.get("redirect_uri")).toBe("http://localhost/auth/callback"); + expect(target.searchParams.get("scope")).toContain("openid"); + expect(target.searchParams.get("code_challenge_method")).toBe("S256"); + expect(target.searchParams.get("code_challenge")).toBeTruthy(); + // The challenge is derived, never the raw verifier. + expect(target.searchParams.get("code_challenge")).not.toBe(flow.verifier); + expect(target.searchParams.get("state")).toBe(flow.state); + expect(target.searchParams.get("nonce")).toBe(flow.nonce); + }); + + test("the flow cookie is Lax so the provider's redirect can carry it", async () => { + const res = await get("/auth/login"); + const entry = res.headers.getSetCookie().find((value) => value.startsWith(`${oidcStateCookieName}=`)) ?? ""; + + // Strict would drop the cookie on the cross-site callback navigation and + // every single sign-on attempt would fail with a state error. + expect(entry).toContain("SameSite=Lax"); + expect(entry).toContain("HttpOnly"); + }); +}); + +describe("callback", () => { + test("issues an OIDC session and clears the flow cookie", async () => { + const res = await callback(); + + expect(res.status).toBe(303); + expect(res.headers.get("location")).toBe("/"); + + const session = cookieValue(res, "filegate_admin"); + expect(session).toBeTruthy(); + + const payload = unseal<{ sub: string; label: string; kind: string }>(session); + expect(payload?.kind).toBe("oidc"); + expect(payload?.sub).toBe("user-1"); + // email is preferred over sub as the human-readable label. + expect(payload?.label).toBe("ada@example.com"); + + const cleared = res.headers.getSetCookie().find((value) => value.startsWith(`${oidcStateCookieName}=`)); + expect(cleared).toMatch(/Max-Age=0|Expires=Thu, 01 Jan 1970/); + }); + + test("the issued session opens authenticated pages", async () => { + const login = await callback(); + const res = await get("/", `filegate_admin=${cookieValue(login, "filegate_admin")}`); + + expect(res.status).toBe(200); + }); + + test("uses basic auth for the code exchange and sends the PKCE verifier", async () => { + idp.tokenRequests.length = 0; + await callback(); + + const request = idp.tokenRequests.at(-1); + expect(request?.authorization).toStartWith("Basic "); + expect(request?.body.grant_type).toBe("authorization_code"); + expect(request?.body.code_verifier).toBeTruthy(); + // Credentials must not also travel in the body when basic auth is used. + expect(request?.body.client_secret).toBeUndefined(); + }); + + test("falls back to a display name, then the subject, when email is absent", async () => { + const withName = await callback({ email: undefined, name: "Ada Lovelace" }); + expect(unseal<{ label: string }>(cookieValue(withName, "filegate_admin"))?.label).toBe("Ada Lovelace"); + + const bare = await callback({ email: undefined, name: undefined, sub: "opaque-subject" }); + expect(unseal<{ label: string }>(cookieValue(bare, "filegate_admin"))?.label).toBe("opaque-subject"); + }); +}); + +describe("callback rejections", () => { + test("rejects a mismatched state", async () => { + const res = await callback({ state: "not-the-state" }); + + expect(reasonOf(res)).toBe("state"); + expect(cookieValue(res, "filegate_admin")).toBeUndefined(); + }); + + test("rejects a callback with no flow cookie", async () => { + const res = await get("/auth/callback?code=abc&state=abc"); + + expect(reasonOf(res)).toBe("state"); + expect(cookieValue(res, "filegate_admin")).toBeUndefined(); + }); + + test("rejects a callback with no authorization code", async () => { + const { flow, cookie } = await beginFlow(); + const res = await get(`/auth/callback?state=${flow.state}`, cookie); + + expect(reasonOf(res)).toBe("state"); + }); + + test("surfaces a provider-reported error", async () => { + const { flow, cookie } = await beginFlow(); + const res = await get(`/auth/callback?error=access_denied&state=${flow.state}`, cookie); + + expect(reasonOf(res)).toBe("provider"); + }); + + test("rejects a replayed nonce from another attempt", async () => { + const res = await callback({ nonce: "nonce-from-a-different-login" }); + + expect(reasonOf(res)).toBe("nonce"); + expect(cookieValue(res, "filegate_admin")).toBeUndefined(); + }); + + test("rejects a token signed by an unknown key", async () => { + const res = await callback({ signWithUnknownKey: true }); + + expect(reasonOf(res)).toBe("token"); + expect(cookieValue(res, "filegate_admin")).toBeUndefined(); + }); + + test("rejects a token issued for a different audience", async () => { + const res = await callback({ audience: "some-other-client" }); + + expect(reasonOf(res)).toBe("token"); + }); + + test("rejects a token response without an id_token", async () => { + const res = await callback({ omitIdToken: true }); + + expect(reasonOf(res)).toBe("token"); + }); +}); + +describe("group allowlist", () => { + test("admits a member of an allowed group", async () => { + const res = await callback({ groups: ["users", "filegate-admins"] }); + + expect(res.headers.get("location")).toBe("/"); + }); + + test("rejects an account outside the allowed groups", async () => { + const res = await callback({ groups: ["users"] }); + + expect(reasonOf(res)).toBe("group"); + expect(cookieValue(res, "filegate_admin")).toBeUndefined(); + }); + + test("rejects an account with no groups at all", async () => { + const res = await callback({ groups: undefined }); + + expect(reasonOf(res)).toBe("group"); + }); + + test("accepts a delimited group string, as some providers send", async () => { + const res = await callback({ groups: "users filegate-admins" }); + + expect(res.headers.get("location")).toBe("/"); + }); +}); + +describe("coexistence with the token login", () => { + test("both sign-in options are offered when both are configured", async () => { + const body = await (await get("/login")).text(); + + expect(body).toContain("/auth/login"); + expect(body).toContain('name="token"'); + }); + + test("the token login still works", async () => { + const res = await app.fetch( + new Request("http://localhost/login", { + method: "POST", + body: new URLSearchParams({ token: adminToken }), + headers: { "content-type": "application/x-www-form-urlencoded", host: "localhost" }, + redirect: "manual", + }), + ); + + expect(res.headers.get("location")).toBe("/"); + expect(unseal<{ kind: string }>(cookieValue(res, "filegate_admin"))?.kind).toBe("token"); + }); +}); diff --git a/docs-site/docs/en/admin.md b/docs-site/docs/en/admin.md index f1a1225..b8301fc 100644 --- a/docs-site/docs/en/admin.md +++ b/docs-site/docs/en/admin.md @@ -37,16 +37,46 @@ The Filegate bearer token stays on the admin server. Browser uploads and downloa |---|---:|---:|---| | `FILEGATE_URL` | Admin server process | Yes | REST API URL reachable from the admin app. | | `FILEGATE_TOKEN` | Admin server process | Yes | Filegate bearer token kept server-side. | -| `ADMIN_TOKEN` | Browser login | Yes | Admin login token. Must differ from `FILEGATE_TOKEN`. | +| `ADMIN_TOKEN` | Browser login | See note | Admin login token. Must differ from `FILEGATE_TOKEN`. Required unless OIDC is configured. | | `ADMIN_SESSION_SECRET` | Browser session cookie | No | Session signing secret. Generated at boot when unset; sessions then survive neither a restart nor a second replica. | | `PORT` | Admin server process | No | HTTP listen port. Defaults to `3000`. | | `ADMIN_TRUST_PROXY` | Rate limiting | No | Set when a reverse proxy fronts the admin app, so `X-Forwarded-For` identifies the client instead of the socket address. | | `ADMIN_COOKIE_SECURE` | Browser session cookie | No | `auto` (default), `true` or `false`. Auto sets `Secure` unless the request host is localhost. | | `REDIS_URL` | Rate limiting | No | Shares the login rate limit across replicas. In-memory when unset. | -`ADMIN_TOKEN` is required and must differ from `FILEGATE_TOKEN`. It previously -defaulted to it, which meant brute-forcing the admin login yielded the Filegate -master token; startup now refuses that configuration. +`ADMIN_TOKEN` must differ from `FILEGATE_TOKEN`. It previously defaulted to it, +which meant brute-forcing the admin login yielded the Filegate master token; +startup now refuses that configuration. + +## Single sign-on + +The admin app supports OIDC single sign-on with the authorization code flow and +PKCE. Setting these four together enables it; leave them unset for token login. + +| Variable | Required | Meaning | +|---|---:|---| +| `OIDC_ISSUER` | Yes | Issuer URL. Discovery reads `/.well-known/openid-configuration`. Must use https outside localhost. | +| `OIDC_CLIENT_ID` | Yes | Client id. | +| `OIDC_CLIENT_SECRET` | Yes | Client secret, kept server-side. | +| `OIDC_REDIRECT_URL` | Yes | Must match the client's configured redirect URI and end in `/auth/callback`. | +| `OIDC_SCOPES` | No | Defaults to `openid profile email`. | +| `OIDC_GROUPS_CLAIM` | No | Claim carrying group membership. Defaults to `groups`. | +| `OIDC_ALLOWED_GROUPS` | No | Comma-separated group allowlist. | + +The ID token is verified against the provider's JWKS with issuer, audience and +nonce all checked. Sessions last 12 hours and are never refreshed; the admin app +does not talk to the provider again after login. Logout is local and does not end +the provider session. + +`OIDC_ALLOWED_GROUPS` is optional on purpose. Providers such as Authentik bind a +group policy to the application itself, so a second allowlist in the admin would +be duplicate bookkeeping. When it is unset, any account the provider lets through +this client becomes an admin, and the admin logs a warning at startup stating +that access control is delegated to the identity provider. + +Keeping `ADMIN_TOKEN` alongside OIDC gives you a break-glass login for when the +provider is unreachable. Omitting it makes single sign-on the only way in and +removes the token form from the login page. ## Login and sessions From 81001e8630018f24d7c0c459a21ec01da32eecb0 Mon Sep 17 00:00:00 2001 From: valentinkolb Date: Sat, 25 Jul 2026 22:37:24 +0200 Subject: [PATCH 04/49] feat(infra): expose jobs, cache and detector internals These subsystems measured useful state and kept it to themselves, so an operator had no way to see queue saturation, cache effectiveness, or a detector falling behind. - jobs: Stats reports workers, queue depth against capacity, in-flight count, and cumulative ErrQueueFull rejections and job panics. Queue pressure previously surfaced only as a 503 to the caller that hit it. - cache: Stats reports entries, capacity and cumulative hits/misses. Hit ratio was tracked nowhere, for either the path or thumbnail cache. - detect: Stats joins the Runner interface, reporting backend, interval, cycles, last scan time and duration, scan errors and outbound channel depth. The btrfs backend also reports the last generation per base path, which makes detector lag computable for the first time. Also counts scan errors that were previously silently swallowed: poll lstat failures other than "gone", and every logged btrfs failure. --- infra/cache/lru.go | 56 +++++++++++++++++++++++++-- infra/cache/lru_test.go | 66 ++++++++++++++++++++++++++++++++ infra/detect/btrfs.go | 42 +++++++++++++++++++- infra/detect/detector.go | 32 ++++++++++++++++ infra/detect/poll.go | 35 ++++++++++++++++- infra/jobs/scheduler.go | 37 ++++++++++++++++++ infra/jobs/stats_test.go | 83 ++++++++++++++++++++++++++++++++++++++++ 7 files changed, 345 insertions(+), 6 deletions(-) create mode 100644 infra/cache/lru_test.go create mode 100644 infra/jobs/stats_test.go diff --git a/infra/cache/lru.go b/infra/cache/lru.go index fb79799..9db1add 100644 --- a/infra/cache/lru.go +++ b/infra/cache/lru.go @@ -1,11 +1,39 @@ package cache -import lru "github.com/hashicorp/golang-lru/v2" +import ( + "sync/atomic" + + lru "github.com/hashicorp/golang-lru/v2" +) + +// Stats is a point-in-time view of cache occupancy and effectiveness. +// +// Hits and Misses are cumulative since process start, so a caller that wants a +// rate should sample twice and subtract rather than expecting a windowed value. +type Stats struct { + Entries int + Capacity int + Hits uint64 + Misses uint64 +} + +// HitRatio reports hits as a fraction of lookups, or 0 when nothing was looked +// up yet. +func (s Stats) HitRatio() float64 { + total := s.Hits + s.Misses + if total == 0 { + return 0 + } + return float64(s.Hits) / float64(total) +} // LRU wraps the underlying cache implementation so shared cache behavior // can be reused across HTTP adapters. type LRU[K comparable, V any] struct { - cache *lru.Cache[K, V] + cache *lru.Cache[K, V] + capacity int + hits atomic.Uint64 + misses atomic.Uint64 } // NewLRU creates an LRU cache with the given capacity. If size is <= 0, defaults to 1024. @@ -17,7 +45,7 @@ func NewLRU[K comparable, V any](size int) (*LRU[K, V], error) { if err != nil { return nil, err } - return &LRU[K, V]{cache: c}, nil + return &LRU[K, V]{cache: c, capacity: size}, nil } func (l *LRU[K, V]) Get(key K) (V, bool) { @@ -25,7 +53,13 @@ func (l *LRU[K, V]) Get(key K) (V, bool) { var zero V return zero, false } - return l.cache.Get(key) + value, ok := l.cache.Get(key) + if ok { + l.hits.Add(1) + } else { + l.misses.Add(1) + } + return value, ok } func (l *LRU[K, V]) Add(key K, value V) { @@ -41,3 +75,17 @@ func (l *LRU[K, V]) Remove(key K) { } l.cache.Remove(key) } + +// Stats reports occupancy and cumulative hit/miss counts. Safe on a nil cache, +// which reports zeroes. +func (l *LRU[K, V]) Stats() Stats { + if l == nil || l.cache == nil { + return Stats{} + } + return Stats{ + Entries: l.cache.Len(), + Capacity: l.capacity, + Hits: l.hits.Load(), + Misses: l.misses.Load(), + } +} diff --git a/infra/cache/lru_test.go b/infra/cache/lru_test.go new file mode 100644 index 0000000..fe453df --- /dev/null +++ b/infra/cache/lru_test.go @@ -0,0 +1,66 @@ +package cache + +import "testing" + +func TestStatsCountsHitsAndMisses(t *testing.T) { + c, err := NewLRU[string, int](8) + if err != nil { + t.Fatalf("NewLRU: %v", err) + } + + c.Add("a", 1) + if _, ok := c.Get("a"); !ok { + t.Fatal("expected hit for a") + } + for range 3 { + if _, ok := c.Get("missing"); ok { + t.Fatal("expected miss for missing") + } + } + + got := c.Stats() + if got.Hits != 1 { + t.Errorf("hits = %d, want 1", got.Hits) + } + if got.Misses != 3 { + t.Errorf("misses = %d, want 3", got.Misses) + } + if got.Entries != 1 { + t.Errorf("entries = %d, want 1", got.Entries) + } + if got.Capacity != 8 { + t.Errorf("capacity = %d, want 8", got.Capacity) + } + if want := 0.25; got.HitRatio() != want { + t.Errorf("hit ratio = %v, want %v", got.HitRatio(), want) + } +} + +func TestStatsHitRatioWithoutLookups(t *testing.T) { + c, err := NewLRU[string, int](4) + if err != nil { + t.Fatalf("NewLRU: %v", err) + } + if ratio := c.Stats().HitRatio(); ratio != 0 { + t.Errorf("hit ratio = %v, want 0 before any lookup", ratio) + } +} + +func TestStatsOnNilCache(t *testing.T) { + // Callers hold optional caches; Stats has to stay safe on the nil path the + // way Get and Add already are. + var c *LRU[string, int] + if got := c.Stats(); got != (Stats{}) { + t.Errorf("nil cache stats = %+v, want zero value", got) + } +} + +func TestCapacityDefaultIsReported(t *testing.T) { + c, err := NewLRU[string, int](0) + if err != nil { + t.Fatalf("NewLRU: %v", err) + } + if got := c.Stats().Capacity; got != 1024 { + t.Errorf("capacity = %d, want the 1024 default", got) + } +} diff --git a/infra/detect/btrfs.go b/infra/detect/btrfs.go index 1134b52..71c54ec 100644 --- a/infra/detect/btrfs.go +++ b/infra/detect/btrfs.go @@ -38,6 +38,12 @@ type BTRFSDetector struct { mu sync.Mutex lastGeneration map[string]uint64 + + // Observability, guarded by mu together with lastGeneration. + cycle uint64 + lastScanAt time.Time + lastScanDuration time.Duration + scanErrors uint64 } // NewBTRFSDetector creates a btrfs-optimized change detector for the given paths. @@ -133,6 +139,7 @@ func (d *BTRFSDetector) initialize(ctx context.Context) { for _, basePath := range d.basePaths { gen, err := currentGeneration(ctx, basePath) if err != nil { + d.scanErrors++ log.Printf("[filegate] btrfs detector init failed for %q: %v", basePath, err) continue } @@ -141,15 +148,22 @@ func (d *BTRFSDetector) initialize(ctx context.Context) { } func (d *BTRFSDetector) poll(ctx context.Context) []Event { + started := time.Now() d.mu.Lock() - defer d.mu.Unlock() + defer func() { + d.lastScanAt = time.Now() + d.lastScanDuration = time.Since(started) + d.mu.Unlock() + }() + d.cycle++ batch := make([]Event, 0, 64) for _, basePath := range d.basePaths { prev := d.lastGeneration[basePath] if prev == 0 { gen, err := currentGeneration(ctx, basePath) if err != nil { + d.scanErrors++ log.Printf("[filegate] btrfs detector generation read failed for %q: %v", basePath, err) continue } @@ -159,6 +173,7 @@ func (d *BTRFSDetector) poll(ctx context.Context) []Event { current, err := currentGeneration(ctx, basePath) if err != nil { + d.scanErrors++ log.Printf("[filegate] btrfs detector generation read failed for %q: %v", basePath, err) continue } @@ -175,6 +190,7 @@ func (d *BTRFSDetector) poll(ctx context.Context) []Event { events, nextGen, err := d.deltaEvents(ctx, basePath, prev, current) if err != nil { + d.scanErrors++ log.Printf("[filegate] btrfs delta scan failed for %q: %v", basePath, err) batch = append(batch, Event{Type: EventUnknown, Base: basePath, AbsPath: basePath, IsDir: true}) d.lastGeneration[basePath] = current @@ -360,3 +376,27 @@ func inodeToPaths(ctx context.Context, basePath string, inode uint64) ([]string, } return paths, nil } + +// Stats reports scan progress plus the last observed generation per base path, +// which is what makes detector lag measurable on btrfs. +func (d *BTRFSDetector) Stats() Stats { + d.mu.Lock() + defer d.mu.Unlock() + + generations := make(map[string]uint64, len(d.lastGeneration)) + for path, gen := range d.lastGeneration { + generations[path] = gen + } + + return Stats{ + Backend: d.Name(), + Interval: d.interval, + Cycles: d.cycle, + LastScanAt: d.lastScanAt, + LastScanDuration: d.lastScanDuration, + Errors: d.scanErrors, + PendingBatches: len(d.events), + QueueCapacity: cap(d.events), + Generations: generations, + } +} diff --git a/infra/detect/detector.go b/infra/detect/detector.go index 18a7ddd..902e524 100644 --- a/infra/detect/detector.go +++ b/infra/detect/detector.go @@ -27,6 +27,37 @@ type Event struct { MtimeMS int64 } +// Stats is a point-in-time view of a detector's health. +// +// Detection is what keeps the index consistent with filesystem writes that did +// not come through the API, so a stalled detector means silent drift. LastScanAt +// falling behind Interval is the signal for that; Errors and PendingBatches say +// why. Backend-specific fields are zero on the backend that does not track them. +type Stats struct { + Backend string + Interval time.Duration + + // Cycles counts completed scan rounds since start. + Cycles uint64 + LastScanAt time.Time + // LastScanDuration is how long the most recent round took. + LastScanDuration time.Duration + Errors uint64 + + // PendingBatches is the depth of the outbound event channel. A sustained + // non-zero value means the consumer cannot keep up with detection. + PendingBatches int + QueueCapacity int + + // TrackedDirs and TrackedFiles are poll-backend only. + TrackedDirs int + TrackedFiles int + + // Generations is the last observed btrfs generation per base path, + // btrfs-backend only. + Generations map[string]uint64 +} + // Runner is the interface for pluggable filesystem change detection backends. type Runner interface { Start(context.Context) @@ -34,6 +65,7 @@ type Runner interface { ForceRescan(context.Context) error Close() Name() string + Stats() Stats } // New creates a Runner for the specified backend ("auto", "poll", or "btrfs"). diff --git a/infra/detect/poll.go b/infra/detect/poll.go index c8e2247..6d30a04 100644 --- a/infra/detect/poll.go +++ b/infra/detect/poll.go @@ -51,6 +51,11 @@ type Poller struct { knownDirs map[string]int64 knownFiles map[string]fileTrack cycle uint64 + + // Observability, guarded by mu together with the tracking maps. + lastScanAt time.Time + lastScanDuration time.Duration + scanErrors uint64 } // NewPoller creates a polling-based change detector for the given paths. @@ -176,8 +181,13 @@ func (p *Poller) initialize() { } func (p *Poller) poll() []Event { + started := time.Now() p.mu.Lock() - defer p.mu.Unlock() + defer func() { + p.lastScanAt = time.Now() + p.lastScanDuration = time.Since(started) + p.mu.Unlock() + }() p.cycle++ batch := make([]Event, 0, 128) @@ -192,6 +202,10 @@ func (p *Poller) poll() []Event { deletedDirs[dirPath] = struct{}{} continue } + // Anything other than "gone" is a real problem (permissions, I/O) + // and the directory silently stops being watched. Count it so the + // condition is at least visible. + p.scanErrors++ continue } if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { @@ -485,3 +499,22 @@ func dedupeEvents(events []Event) []Event { sort.Slice(out, func(i, j int) bool { return out[i].AbsPath < out[j].AbsPath }) return out } + +// Stats reports poll-cycle progress and tracking-set size. +func (p *Poller) Stats() Stats { + p.mu.Lock() + defer p.mu.Unlock() + + return Stats{ + Backend: p.Name(), + Interval: p.interval, + Cycles: p.cycle, + LastScanAt: p.lastScanAt, + LastScanDuration: p.lastScanDuration, + Errors: p.scanErrors, + PendingBatches: len(p.events), + QueueCapacity: cap(p.events), + TrackedDirs: len(p.knownDirs), + TrackedFiles: len(p.knownFiles), + } +} diff --git a/infra/jobs/scheduler.go b/infra/jobs/scheduler.go index 5fa940e..3db7f5c 100644 --- a/infra/jobs/scheduler.go +++ b/infra/jobs/scheduler.go @@ -20,6 +20,20 @@ var ( // JobFunc is the signature for a background job executed by the Scheduler. type JobFunc func(context.Context) (any, error) +// Stats is a point-in-time view of scheduler pressure. +// +// Queued against QueueCapacity is the signal that matters operationally: once +// the queue fills, submissions are rejected with ErrQueueFull, which callers +// surface as 503. Rejected is cumulative since process start. +type Stats struct { + Workers int + Queued int + QueueCapacity int + InFlight int + Rejected uint64 + Panics uint64 +} + // Scheduler is a bounded worker pool with keyed job deduplication. type Scheduler struct { ctx context.Context @@ -37,6 +51,11 @@ type Scheduler struct { // counter from reaching zero. activeWorkers atomic.Int32 workersDone chan struct{} + + // Cumulative counters for observability. Rejected tracks ErrQueueFull, + // which is otherwise only visible to the caller that hit it. + rejected atomic.Uint64 + panics atomic.Uint64 } type jobCall struct { @@ -147,6 +166,7 @@ func (s *Scheduler) worker() { func (s *Scheduler) runCall(call *jobCall) { defer func() { if r := recover(); r != nil { + s.panics.Add(1) call.err = fmt.Errorf("job panic: %v\n%s", r, string(debug.Stack())) } close(call.done) @@ -191,6 +211,7 @@ func (s *Scheduler) getOrSubmit(key string, fn JobFunc) (*jobCall, bool, error) case s.queue <- call: return call, true, nil default: + s.rejected.Add(1) call.err = ErrQueueFull close(call.done) s.inFlight.Delete(key) @@ -198,6 +219,22 @@ func (s *Scheduler) getOrSubmit(key string, fn JobFunc) (*jobCall, bool, error) } } +// Stats reports current pressure and cumulative rejections. Safe on a nil +// scheduler, which reports zeroes. +func (s *Scheduler) Stats() Stats { + if s == nil { + return Stats{} + } + return Stats{ + Workers: int(s.activeWorkers.Load()), + Queued: len(s.queue), + QueueCapacity: cap(s.queue), + InFlight: s.inFlight.Size(), + Rejected: s.rejected.Load(), + Panics: s.panics.Load(), + } +} + func waitForResult(ctx context.Context, call *jobCall) (any, error) { select { case <-ctx.Done(): diff --git a/infra/jobs/stats_test.go b/infra/jobs/stats_test.go new file mode 100644 index 0000000..b21c97f --- /dev/null +++ b/infra/jobs/stats_test.go @@ -0,0 +1,83 @@ +package jobs + +import ( + "context" + "errors" + "fmt" + "testing" + "time" +) + +func TestStatsReportsPoolShape(t *testing.T) { + s := New(2, 16) + defer func() { _ = s.Close(context.Background()) }() + + got := s.Stats() + if got.Workers != 2 { + t.Errorf("workers = %d, want 2", got.Workers) + } + if got.QueueCapacity != 16 { + t.Errorf("queue capacity = %d, want 16", got.QueueCapacity) + } + if got.Queued != 0 { + t.Errorf("queued = %d, want 0 on an idle scheduler", got.Queued) + } +} + +func TestStatsCountsQueueFullRejections(t *testing.T) { + // One worker, one queue slot: block the worker, fill the slot, and the next + // submission has nowhere to go. That rejection is invisible today except to + // the caller that hit it. + s := New(1, 1) + defer func() { _ = s.Close(context.Background()) }() + + release := make(chan struct{}) + started := make(chan struct{}) + go func() { + _, _ = s.Do(context.Background(), "blocker", func(context.Context) (any, error) { + close(started) + <-release + return nil, nil + }) + }() + <-started + defer close(release) + + // Submit without waiting for results: Do would block on the busy worker. + noop := func(context.Context) (any, error) { return nil, nil } + var rejected bool + deadline := time.Now().Add(2 * time.Second) + for i := 0; !rejected && time.Now().Before(deadline); i++ { + _, _, err := s.getOrSubmit(fmt.Sprintf("overflow-%d", i), noop) + rejected = errors.Is(err, ErrQueueFull) + } + if !rejected { + t.Fatal("never observed ErrQueueFull") + } + + if got := s.Stats().Rejected; got == 0 { + t.Error("rejected counter stayed 0 after ErrQueueFull") + } +} + +func TestStatsCountsPanics(t *testing.T) { + s := New(1, 4) + defer func() { _ = s.Close(context.Background()) }() + + _, err := s.Do(context.Background(), "boom", func(context.Context) (any, error) { + panic("boom") + }) + if err == nil { + t.Fatal("expected the panic to surface as an error") + } + if got := s.Stats().Panics; got != 1 { + t.Errorf("panics = %d, want 1", got) + } +} + +func TestStatsOnNilScheduler(t *testing.T) { + var s *Scheduler + if got := s.Stats(); got.Workers != 0 || got.QueueCapacity != 0 { + t.Errorf("nil scheduler stats = %+v, want zero value", got) + } +} From a987d40dd571b825b14e036a1f0cf5ce362b3ff7 Mon Sep 17 00:00:00 2001 From: valentinkolb Date: Sat, 25 Jul 2026 22:53:17 +0200 Subject: [PATCH 05/49] feat(api): add system info, runtime and health endpoints Exposes state the server already tracked but kept in logs, Prometheus, or memory. All additive; no existing route changes behavior. - GET /v1/system/info: build version and commit, uptime, detector backend, effective versioning mode, per-mount health including writable and xattr support, and a curated set of limits. Mount health was computed once at startup and thrown away. Config exposure is an explicit allowlist, not a dump, so no secret can leak by default. - GET /v1/system/runtime: detector cycles, staleness and per-path btrfs generations, worker pool saturation, path and thumbnail cache hit ratios, and upload sessions by phase with write-slot usage. Every field is an in-memory counter, so this one is safe to poll. - GET /v1/health: real dependency checks (index, detector, mounts), returning 503 on failure. The bare GET /health stays byte-identical for existing probes. - GET /v1/uploads/sessions: lists sessions, optionally by phase. An interrupted upload previously left a session nothing could find, since abort needs an id that was no longer known anywhere. Health deliberately probes only mount existence; the write probe stays on system/info so a polled endpoint never writes to every mount. Adds Service.PingIndex as a cheap liveness probe, since Stats walks every entity and is unfit for a health check. --- adapter/http/router.go | 27 +++ adapter/http/system.go | 368 ++++++++++++++++++++++++++++++ adapter/http/system_linux_test.go | 331 +++++++++++++++++++++++++++ adapter/http/thumbnail.go | 16 ++ adapter/http/upload_sessions.go | 16 ++ api/v1/system.go | 189 +++++++++++++++ cli/serve.go | 12 + domain/service.go | 31 ++- domain/service_upload_session.go | 14 ++ 9 files changed, 1000 insertions(+), 4 deletions(-) create mode 100644 adapter/http/system.go create mode 100644 adapter/http/system_linux_test.go create mode 100644 api/v1/system.go diff --git a/adapter/http/router.go b/adapter/http/router.go index 6538809..a914596 100644 --- a/adapter/http/router.go +++ b/adapter/http/router.go @@ -30,6 +30,7 @@ import ( apiv1 "github.com/valentinkolb/filegate/api/v1" "github.com/valentinkolb/filegate/domain" "github.com/valentinkolb/filegate/infra/activity" + "github.com/valentinkolb/filegate/infra/detect" "github.com/valentinkolb/filegate/infra/jobs" ) @@ -70,6 +71,26 @@ type RouterOptions struct { MetricsPath string MetricsToken string ActivityLog *activity.Ring + + // Operational context for GET /v1/system/info, /v1/system/runtime and + // /v1/health. All optional: zero values degrade the reported detail + // rather than breaking the endpoints, which keeps existing router + // callers (including tests) working unchanged. + BuildVersion string + BuildCommit string + BasePaths []string + // PathCacheSize is the configured capacity, reported alongside the live + // occupancy the service tracks. + PathCacheSize int + // DetectorStats returns live detector state. Nil means the router reports + // an unknown backend instead of guessing. + DetectorStats func() detect.Stats + + VersioningEnabled bool + VersioningMode string + VersioningCooldown time.Duration + VersioningPrunerInterval time.Duration + VersioningMaxPinnedPerFile int } type closeableHandler struct { @@ -178,6 +199,12 @@ func NewRouter(svc *domain.Service, opts RouterOptions) http.Handler { root.Handle(pattern, auth(http.HandlerFunc(handler))) } + system := newSystemReporter(svc, opts, thumbs, uploadSessions) + handleV1("GET /v1/system/info", system.handleInfo) + handleV1("GET /v1/system/runtime", system.handleRuntime) + handleV1("GET /v1/health", system.handleHealth) + handleV1("GET /v1/uploads/sessions", system.handleListUploadSessions) + handleV1("GET /v1/stats", func(w http.ResponseWriter, _ *http.Request) { stats, err := svc.Stats() if err != nil { diff --git a/adapter/http/system.go b/adapter/http/system.go new file mode 100644 index 0000000..921e439 --- /dev/null +++ b/adapter/http/system.go @@ -0,0 +1,368 @@ +package httpadapter + +import ( + "net/http" + "os" + "path/filepath" + "runtime" + "time" + + apiv1 "github.com/valentinkolb/filegate/api/v1" + "github.com/valentinkolb/filegate/domain" + "github.com/valentinkolb/filegate/infra/cache" + "github.com/valentinkolb/filegate/infra/detect" + "github.com/valentinkolb/filegate/infra/filesystem" + "github.com/valentinkolb/filegate/infra/jobs" +) + +// systemReporter answers the operational endpoints. It reads state that the +// server already tracks; nothing here mutates anything. +type systemReporter struct { + svc *domain.Service + opts RouterOptions + thumbs *thumbnailer + uploads *uploadSessionManager + startedAt time.Time +} + +func newSystemReporter(svc *domain.Service, opts RouterOptions, thumbs *thumbnailer, uploads *uploadSessionManager) *systemReporter { + return &systemReporter{ + svc: svc, + opts: opts, + thumbs: thumbs, + uploads: uploads, + startedAt: time.Now(), + } +} + +func (r *systemReporter) detectorStats() detect.Stats { + if r.opts.DetectorStats == nil { + return detect.Stats{Backend: "unknown"} + } + return r.opts.DetectorStats() +} + +// handleInfo serves GET /v1/system/info. It probes mount health, so it touches +// the filesystem and is meant to be read occasionally rather than polled. +func (r *systemReporter) handleInfo(w http.ResponseWriter, _ *http.Request) { + now := time.Now() + detector := r.detectorStats() + + info := apiv1.SystemInfoResponse{ + GeneratedAt: now.UnixMilli(), + Build: apiv1.BuildInfo{ + Version: fallback(r.opts.BuildVersion, "dev"), + Commit: fallback(r.opts.BuildCommit, "none"), + Go: runtime.Version(), + }, + StartedAt: r.startedAt.UnixMilli(), + UptimeMs: now.Sub(r.startedAt).Milliseconds(), + Detector: apiv1.DetectorInfo{ + Backend: detector.Backend, + IntervalMs: detector.Interval.Milliseconds(), + }, + Versioning: apiv1.VersioningInfo{ + Enabled: r.opts.VersioningEnabled, + Mode: fallback(r.opts.VersioningMode, "auto"), + CooldownMs: r.opts.VersioningCooldown.Milliseconds(), + PrunerIntervalMs: r.opts.VersioningPrunerInterval.Milliseconds(), + MaxPinnedPerFile: r.opts.VersioningMaxPinnedPerFile, + }, + Limits: apiv1.LimitsInfo{ + MaxChunkBytes: r.opts.MaxChunkBytes, + MaxUploadBytes: r.opts.MaxUploadBytes, + MaxSessionUploadBytes: r.opts.MaxSessionUploadBytes, + MaxConcurrentSegmentWrites: r.opts.MaxConcurrentSegmentWrites, + UploadMinFreeBytes: r.opts.UploadMinFreeBytes, + UploadExpiryMs: r.opts.UploadExpiry.Milliseconds(), + UploadCleanupIntervalMs: r.opts.UploadCleanupInterval.Milliseconds(), + ThumbnailMaxSourceBytes: r.opts.ThumbnailMaxSourceBytes, + ThumbnailMaxPixels: r.opts.ThumbnailMaxPixels, + PathCacheCapacity: r.opts.PathCacheSize, + ActivityRingCapacity: r.opts.ActivityLog.Capacity(), + }, + Mounts: r.mountInfo(), + IndexPath: r.opts.IndexPath, + } + + writeJSON(w, http.StatusOK, info) +} + +func (r *systemReporter) mountInfo() []apiv1.MountInfo { + paths := r.opts.BasePaths + out := make([]apiv1.MountInfo, 0, len(paths)) + for _, health := range filesystem.CheckMountsHealth(paths) { + out = append(out, apiv1.MountInfo{ + Name: filepath.Base(health.Path), + Path: health.Path, + Exists: health.Exists, + Writable: health.Writable, + XAttrSupported: health.XAttrSupported, + FreeBytes: health.FreeBytes, + TotalBytes: health.TotalBytes, + Errors: health.Errors, + }) + } + return out +} + +// handleRuntime serves GET /v1/system/runtime. Every value is an in-memory +// counter, so this is the endpoint a dashboard should poll. +func (r *systemReporter) handleRuntime(w http.ResponseWriter, _ *http.Request) { + now := time.Now() + detector := r.detectorStats() + + staleFor := int64(0) + if !detector.LastScanAt.IsZero() { + staleFor = now.Sub(detector.LastScanAt).Milliseconds() + } + lastScanAt := int64(0) + if !detector.LastScanAt.IsZero() { + lastScanAt = detector.LastScanAt.UnixMilli() + } + + pathEntries, pathCapacity, pathHits, pathMisses := r.svc.PathCacheStats() + + out := apiv1.SystemRuntimeResponse{ + GeneratedAt: now.UnixMilli(), + Detector: apiv1.DetectorRuntime{ + Backend: detector.Backend, + IntervalMs: detector.Interval.Milliseconds(), + Cycles: detector.Cycles, + LastScanAt: lastScanAt, + LastScanDurationMs: detector.LastScanDuration.Milliseconds(), + StaleForMs: staleFor, + Errors: detector.Errors, + PendingBatches: detector.PendingBatches, + QueueCapacity: detector.QueueCapacity, + TrackedDirs: detector.TrackedDirs, + TrackedFiles: detector.TrackedFiles, + Generations: detector.Generations, + }, + Jobs: jobsRuntime(r.thumbs.schedulerStats()), + PathCache: cacheRuntime(pathEntries, pathCapacity, pathHits, pathMisses), + ThumbnailCache: thumbCacheRuntime(r.thumbs.cacheStats()), + UploadSessions: r.uploadSessionRuntime(), + } + + writeJSON(w, http.StatusOK, out) +} + +func (r *systemReporter) uploadSessionRuntime() apiv1.UploadSessionsRuntime { + out := apiv1.UploadSessionsRuntime{ + WriteSlotsInUse: r.uploads.writeSlotsInUse(), + WriteSlotsLimit: r.uploads.writeSlotsLimit(), + } + counts := map[domain.UploadSessionPhase]*int{ + domain.UploadSessionInProgress: &out.InProgress, + domain.UploadSessionCommitting: &out.Committing, + domain.UploadSessionCommitted: &out.Committed, + domain.UploadSessionAborted: &out.Aborted, + } + for phase, target := range counts { + sessions, err := r.svc.ListUploadSessions(phase) + if err != nil { + continue + } + *target = len(sessions) + } + return out +} + +// handleHealth serves GET /v1/health: a real dependency check, unlike the bare +// GET /health liveness probe which only proves the process is listening. +func (r *systemReporter) handleHealth(w http.ResponseWriter, _ *http.Request) { + checks := make([]apiv1.HealthCheck, 0, 3) + status := apiv1.HealthOK + + degrade := func(to string) { + if to == apiv1.HealthFail { + status = apiv1.HealthFail + return + } + if status == apiv1.HealthOK { + status = apiv1.HealthDegraded + } + } + + // Index: a point lookup is the cheapest proof that Pebble answers. Stats + // would also prove it but walks every entity. + if err := r.svc.PingIndex(); err != nil { + checks = append(checks, apiv1.HealthCheck{Name: "index", Status: apiv1.HealthFail, Detail: err.Error()}) + degrade(apiv1.HealthFail) + } else { + checks = append(checks, apiv1.HealthCheck{Name: "index", Status: apiv1.HealthOK}) + } + + // Detector: silence well past the scan interval means the goroutine died, + // which causes silent index drift rather than an obvious outage. + checks = append(checks, r.detectorHealth(°rade)) + + // Mounts: existence only. Writability needs a write probe, which belongs on + // the occasional /v1/system/info rather than on a pollable health endpoint. + if missing := missingMounts(r.opts.BasePaths); len(missing) > 0 { + checks = append(checks, apiv1.HealthCheck{Name: "mounts", Status: apiv1.HealthFail, Detail: "unreachable: " + joinPaths(missing)}) + degrade(apiv1.HealthFail) + } else { + checks = append(checks, apiv1.HealthCheck{Name: "mounts", Status: apiv1.HealthOK}) + } + + code := http.StatusOK + if status == apiv1.HealthFail { + code = http.StatusServiceUnavailable + } + writeJSON(w, code, apiv1.HealthResponse{ + Status: status, + GeneratedAt: time.Now().UnixMilli(), + Checks: checks, + }) +} + +// detectorStaleFactor is how many scan intervals may elapse before detection is +// considered stalled. Scans can overrun their interval under load, so a small +// multiple avoids flapping while still catching a dead goroutine quickly. +const detectorStaleFactor = 5 + +func (r *systemReporter) detectorHealth(degrade *func(string)) apiv1.HealthCheck { + stats := r.detectorStats() + if stats.Interval <= 0 { + return apiv1.HealthCheck{Name: "detector", Status: apiv1.HealthOK, Detail: "not configured"} + } + if stats.LastScanAt.IsZero() { + // Startup has not completed a first round yet. Not an error on its own. + return apiv1.HealthCheck{Name: "detector", Status: apiv1.HealthOK, Detail: "awaiting first scan"} + } + + stale := time.Since(stats.LastScanAt) + if stale > stats.Interval*detectorStaleFactor { + (*degrade)(apiv1.HealthDegraded) + return apiv1.HealthCheck{ + Name: "detector", + Status: apiv1.HealthDegraded, + Detail: "no scan for " + stale.Round(time.Second).String() + "; external filesystem changes may not be indexed", + } + } + return apiv1.HealthCheck{Name: "detector", Status: apiv1.HealthOK} +} + +// handleListUploadSessions serves GET /v1/uploads/sessions. Without it an +// interrupted upload leaves a session that nothing can find, only abort by id. +func (r *systemReporter) handleListUploadSessions(w http.ResponseWriter, req *http.Request) { + phases := []domain.UploadSessionPhase{ + domain.UploadSessionInProgress, + domain.UploadSessionCommitting, + domain.UploadSessionCommitted, + domain.UploadSessionAborted, + } + if requested := req.URL.Query().Get("phase"); requested != "" { + phase := domain.UploadSessionPhase(requested) + if !validUploadPhase(phase) { + writeErr(w, http.StatusBadRequest, "phase must be one of in_progress, committing, committed, aborted") + return + } + phases = []domain.UploadSessionPhase{phase} + } + + now := time.Now().UnixMilli() + items := make([]apiv1.UploadSessionSummary, 0, 16) + for _, phase := range phases { + sessions, err := r.svc.ListUploadSessions(phase) + if err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + for _, session := range sessions { + items = append(items, r.summarizeSession(session, now)) + } + } + + writeJSON(w, http.StatusOK, apiv1.UploadSessionListResponse{Items: items, Total: len(items)}) +} + +func (r *systemReporter) summarizeSession(session domain.UploadSession, now int64) apiv1.UploadSessionSummary { + uploaded := 0 + var uploadedBytes int64 + if segments, err := r.svc.ListUploadSegments(session.ID); err == nil { + uploaded = len(segments) + for _, segment := range segments { + uploadedBytes += segment.Size + } + } + return apiv1.UploadSessionSummary{ + ID: session.ID, + Path: session.Path, + Size: session.Size, + SegmentSize: session.SegmentSize, + TotalSegments: session.TotalSegments, + UploadedSegments: uploaded, + UploadedBytes: uploadedBytes, + Phase: string(session.Phase), + CreatedAt: session.CreatedAt, + UpdatedAt: session.UpdatedAt, + AgeMs: now - session.CreatedAt, + ContentType: session.ContentType, + } +} + +func validUploadPhase(phase domain.UploadSessionPhase) bool { + switch phase { + case domain.UploadSessionInProgress, domain.UploadSessionCommitting, domain.UploadSessionCommitted, domain.UploadSessionAborted: + return true + default: + return false + } +} + +func jobsRuntime(stats jobs.Stats) apiv1.JobsRuntime { + return apiv1.JobsRuntime{ + Workers: stats.Workers, + Queued: stats.Queued, + QueueCapacity: stats.QueueCapacity, + InFlight: stats.InFlight, + Rejected: stats.Rejected, + Panics: stats.Panics, + } +} + +func thumbCacheRuntime(stats cache.Stats) apiv1.CacheRuntime { + return cacheRuntime(stats.Entries, stats.Capacity, stats.Hits, stats.Misses) +} + +// missingMounts returns the configured mounts that cannot be reached at all. +// Existence only: a write probe belongs on /v1/system/info, not on an endpoint +// meant to be polled. +func missingMounts(paths []string) []string { + var missing []string + for _, path := range paths { + if info, err := os.Stat(path); err != nil || !info.IsDir() { + missing = append(missing, path) + } + } + return missing +} + +func cacheRuntime(entries, capacity int, hits, misses uint64) apiv1.CacheRuntime { + ratio := 0.0 + if total := hits + misses; total > 0 { + ratio = float64(hits) / float64(total) + } + return apiv1.CacheRuntime{Entries: entries, Capacity: capacity, Hits: hits, Misses: misses, HitRatio: ratio} +} + +func fallback(value, def string) string { + if value == "" { + return def + } + return value +} + +func joinPaths(paths []string) string { + out := "" + for i, path := range paths { + if i > 0 { + out += ", " + } + out += path + } + return out +} diff --git a/adapter/http/system_linux_test.go b/adapter/http/system_linux_test.go new file mode 100644 index 0000000..627f12e --- /dev/null +++ b/adapter/http/system_linux_test.go @@ -0,0 +1,331 @@ +//go:build linux + +package httpadapter + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + apiv1 "github.com/valentinkolb/filegate/api/v1" + "github.com/valentinkolb/filegate/domain" + "github.com/valentinkolb/filegate/infra/detect" +) + +func decodeJSON[T any](t *testing.T, r http.Handler, target string) (T, int) { + t.Helper() + + w := httptest.NewRecorder() + r.ServeHTTP(w, authedRequest(http.MethodGet, target)) + + var out T + if w.Result().StatusCode == http.StatusOK || w.Result().StatusCode == http.StatusServiceUnavailable { + if err := json.NewDecoder(w.Result().Body).Decode(&out); err != nil { + t.Fatalf("decode %s: %v", target, err) + } + } + return out, w.Result().StatusCode +} + +func TestSystemInfoReportsBuildMountsAndLimits(t *testing.T) { + base := t.TempDir() + opts := RouterOptions{ + BuildVersion: "1.2.3", + BuildCommit: "abc1234", + BasePaths: []string{base}, + PathCacheSize: 4096, + MaxUploadBytes: 1 << 20, + VersioningEnabled: true, + VersioningMode: "on", + VersioningCooldown: 15 * time.Minute, + VersioningMaxPinnedPerFile: 100, + DetectorStats: func() detect.Stats { + return detect.Stats{Backend: "poll", Interval: 3 * time.Second} + }, + } + r, _, cleanup := newTestRouterWithBasePathsAndOptions(t, []string{base}, opts) + defer cleanup() + + info, status := decodeJSON[apiv1.SystemInfoResponse](t, r, "/v1/system/info") + if status != http.StatusOK { + t.Fatalf("status=%d, want 200", status) + } + + if info.Build.Version != "1.2.3" || info.Build.Commit != "abc1234" { + t.Errorf("build = %+v, want version 1.2.3 commit abc1234", info.Build) + } + if info.Build.Go == "" { + t.Error("build.go is empty") + } + if info.Detector.Backend != "poll" || info.Detector.IntervalMs != 3000 { + t.Errorf("detector = %+v, want poll at 3000ms", info.Detector) + } + if !info.Versioning.Enabled || info.Versioning.Mode != "on" { + t.Errorf("versioning = %+v, want enabled in mode on", info.Versioning) + } + if info.Limits.PathCacheCapacity != 4096 { + t.Errorf("pathCacheCapacity = %d, want 4096", info.Limits.PathCacheCapacity) + } + if len(info.Mounts) != 1 { + t.Fatalf("mounts = %d, want 1", len(info.Mounts)) + } + mount := info.Mounts[0] + if !mount.Exists || !mount.Writable { + t.Errorf("mount = %+v, want an existing writable mount", mount) + } + if mount.Path != base { + t.Errorf("mount path = %q, want %q", mount.Path, base) + } + if info.UptimeMs < 0 { + t.Errorf("uptime = %d, want >= 0", info.UptimeMs) + } +} + +func TestSystemInfoWithoutDetectorReportsUnknownBackend(t *testing.T) { + // The router must stay usable when the caller supplies no detector hook, + // which is how every existing test constructs it. + r, _, cleanup := newTestRouter(t) + defer cleanup() + + info, status := decodeJSON[apiv1.SystemInfoResponse](t, r, "/v1/system/info") + if status != http.StatusOK { + t.Fatalf("status=%d, want 200", status) + } + if info.Detector.Backend != "unknown" { + t.Errorf("detector backend = %q, want unknown", info.Detector.Backend) + } +} + +func TestSystemRuntimeReportsDetectorAndPools(t *testing.T) { + lastScan := time.Now().Add(-2 * time.Second) + opts := RouterOptions{ + DetectorStats: func() detect.Stats { + return detect.Stats{ + Backend: "btrfs", + Interval: 2 * time.Second, + Cycles: 42, + LastScanAt: lastScan, + Errors: 3, + PendingBatches: 1, + QueueCapacity: 64, + Generations: map[string]uint64{"/data": 99}, + } + }, + } + base := t.TempDir() + r, svc, cleanup := newTestRouterWithBasePathsAndOptions(t, []string{base}, opts) + defer cleanup() + + // Touch a path so the cache records at least one lookup. + root := svc.ListRoot()[0] + if _, err := svc.CreateChild(root.ID, "a.txt", false, nil); err != nil { + t.Fatalf("create child: %v", err) + } + + rt, status := decodeJSON[apiv1.SystemRuntimeResponse](t, r, "/v1/system/runtime") + if status != http.StatusOK { + t.Fatalf("status=%d, want 200", status) + } + + if rt.Detector.Backend != "btrfs" || rt.Detector.Cycles != 42 || rt.Detector.Errors != 3 { + t.Errorf("detector = %+v, want btrfs with 42 cycles and 3 errors", rt.Detector) + } + if rt.Detector.Generations["/data"] != 99 { + t.Errorf("generations = %v, want /data at 99", rt.Detector.Generations) + } + if rt.Detector.StaleForMs < 1000 { + t.Errorf("staleForMs = %d, want at least the ~2s since the last scan", rt.Detector.StaleForMs) + } + if rt.Jobs.QueueCapacity <= 0 { + t.Errorf("jobs queue capacity = %d, want > 0", rt.Jobs.QueueCapacity) + } + if rt.PathCache.Capacity <= 0 { + t.Errorf("path cache capacity = %d, want > 0", rt.PathCache.Capacity) + } + if rt.UploadSessions.WriteSlotsLimit <= 0 { + t.Errorf("write slot limit = %d, want > 0", rt.UploadSessions.WriteSlotsLimit) + } +} + +func TestHealthReportsDependencies(t *testing.T) { + base := t.TempDir() + opts := RouterOptions{ + BasePaths: []string{base}, + DetectorStats: func() detect.Stats { + return detect.Stats{Backend: "poll", Interval: 3 * time.Second, LastScanAt: time.Now()} + }, + } + r, _, cleanup := newTestRouterWithBasePathsAndOptions(t, []string{base}, opts) + defer cleanup() + + health, status := decodeJSON[apiv1.HealthResponse](t, r, "/v1/health") + if status != http.StatusOK { + t.Fatalf("status=%d, want 200", status) + } + if health.Status != apiv1.HealthOK { + t.Errorf("status = %q, want ok; checks=%+v", health.Status, health.Checks) + } + if len(health.Checks) != 3 { + t.Fatalf("checks = %d, want index, detector and mounts", len(health.Checks)) + } +} + +func TestHealthDegradesWhenDetectorStalls(t *testing.T) { + // A detector goroutine that died is the failure this endpoint exists for: + // writes made outside the API silently stop being indexed. + base := t.TempDir() + opts := RouterOptions{ + BasePaths: []string{base}, + DetectorStats: func() detect.Stats { + return detect.Stats{ + Backend: "poll", + Interval: time.Second, + LastScanAt: time.Now().Add(-time.Hour), + } + }, + } + r, _, cleanup := newTestRouterWithBasePathsAndOptions(t, []string{base}, opts) + defer cleanup() + + health, status := decodeJSON[apiv1.HealthResponse](t, r, "/v1/health") + if status != http.StatusOK { + t.Fatalf("status=%d, want 200 for degraded", status) + } + if health.Status != apiv1.HealthDegraded { + t.Fatalf("status = %q, want degraded; checks=%+v", health.Status, health.Checks) + } + + var detector *apiv1.HealthCheck + for i := range health.Checks { + if health.Checks[i].Name == "detector" { + detector = &health.Checks[i] + } + } + if detector == nil || detector.Status != apiv1.HealthDegraded { + t.Fatalf("detector check = %+v, want degraded", detector) + } + if detector.Detail == "" { + t.Error("degraded detector check has no detail explaining the staleness") + } +} + +func TestHealthFailsWhenMountIsGone(t *testing.T) { + base := t.TempDir() + opts := RouterOptions{BasePaths: []string{base, base + "-does-not-exist"}} + r, _, cleanup := newTestRouterWithBasePathsAndOptions(t, []string{base}, opts) + defer cleanup() + + health, status := decodeJSON[apiv1.HealthResponse](t, r, "/v1/health") + if status != http.StatusServiceUnavailable { + t.Fatalf("status=%d, want 503 when a mount is unreachable", status) + } + if health.Status != apiv1.HealthFail { + t.Errorf("status = %q, want fail", health.Status) + } +} + +func TestPlainHealthEndpointIsUnchanged(t *testing.T) { + // Existing liveness probes point at GET /health and must keep working. + r, _, cleanup := newTestRouter(t) + defer cleanup() + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/health", nil)) + if w.Result().StatusCode != http.StatusOK { + t.Fatalf("status=%d, want 200", w.Result().StatusCode) + } + if got := w.Body.String(); got != "OK" { + t.Errorf("body = %q, want OK", got) + } +} + +func TestListUploadSessionsSurfacesOrphans(t *testing.T) { + r, svc, cleanup := newTestRouter(t) + defer cleanup() + + root := svc.ListRoot()[0] + session := domain.UploadSession{ + ID: "session-orphan", + Path: root.Name + "/big.bin", + ParentID: root.ID, + Filename: "big.bin", + Size: 4096, + SegmentSize: 1024, + TotalSegments: 4, + Phase: domain.UploadSessionInProgress, + CreatedAt: time.Now().Add(-time.Hour).UnixMilli(), + UpdatedAt: time.Now().Add(-time.Hour).UnixMilli(), + } + if err := svc.CreateUploadSession(session); err != nil { + t.Fatalf("create session: %v", err) + } + + list, status := decodeJSON[apiv1.UploadSessionListResponse](t, r, "/v1/uploads/sessions") + if status != http.StatusOK { + t.Fatalf("status=%d, want 200", status) + } + if list.Total != 1 || len(list.Items) != 1 { + t.Fatalf("total=%d items=%d, want exactly the one orphan", list.Total, len(list.Items)) + } + + item := list.Items[0] + if item.ID != "session-orphan" { + t.Errorf("id = %q, want session-orphan", item.ID) + } + if item.Phase != string(domain.UploadSessionInProgress) { + t.Errorf("phase = %q, want in_progress", item.Phase) + } + if item.AgeMs < int64(time.Minute/time.Millisecond) { + t.Errorf("ageMs = %d, want roughly an hour", item.AgeMs) + } + if item.TotalSegments != 4 || item.UploadedSegments != 0 { + t.Errorf("segments = %d/%d, want 0 of 4", item.UploadedSegments, item.TotalSegments) + } +} + +func TestListUploadSessionsFiltersByPhase(t *testing.T) { + r, svc, cleanup := newTestRouter(t) + defer cleanup() + + root := svc.ListRoot()[0] + for id, phase := range map[string]domain.UploadSessionPhase{ + "live": domain.UploadSessionInProgress, + "stopped": domain.UploadSessionAborted, + } { + if err := svc.CreateUploadSession(domain.UploadSession{ + ID: id, Path: root.Name + "/" + id, ParentID: root.ID, Filename: id, + Phase: phase, CreatedAt: time.Now().UnixMilli(), + }); err != nil { + t.Fatalf("create %s: %v", id, err) + } + } + + list, status := decodeJSON[apiv1.UploadSessionListResponse](t, r, "/v1/uploads/sessions?phase=aborted") + if status != http.StatusOK { + t.Fatalf("status=%d, want 200", status) + } + if list.Total != 1 || list.Items[0].ID != "stopped" { + t.Fatalf("got %+v, want only the aborted session", list.Items) + } + + w := httptest.NewRecorder() + r.ServeHTTP(w, authedRequest(http.MethodGet, "/v1/uploads/sessions?phase=nonsense")) + if w.Result().StatusCode != http.StatusBadRequest { + t.Errorf("unknown phase status=%d, want 400", w.Result().StatusCode) + } +} + +func TestSystemEndpointsRequireAuth(t *testing.T) { + r, _, cleanup := newTestRouter(t) + defer cleanup() + + for _, target := range []string{"/v1/system/info", "/v1/system/runtime", "/v1/health", "/v1/uploads/sessions"} { + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, target, nil)) + if w.Result().StatusCode != http.StatusUnauthorized { + t.Errorf("%s without a token: status=%d, want 401", target, w.Result().StatusCode) + } + } +} diff --git a/adapter/http/thumbnail.go b/adapter/http/thumbnail.go index 80e4ca6..b56ae9f 100644 --- a/adapter/http/thumbnail.go +++ b/adapter/http/thumbnail.go @@ -255,3 +255,19 @@ func (t *thumbnailer) generateOne(absPath string, size int, mtime int64) (thumbn } return item, nil } + +// schedulerStats exposes thumbnail worker-pool pressure for /v1/system/runtime. +func (t *thumbnailer) schedulerStats() jobs.Stats { + if t == nil { + return jobs.Stats{} + } + return t.scheduler.Stats() +} + +// cacheStats exposes thumbnail cache occupancy and effectiveness. +func (t *thumbnailer) cacheStats() cache.Stats { + if t == nil { + return cache.Stats{} + } + return t.cache.Stats() +} diff --git a/adapter/http/upload_sessions.go b/adapter/http/upload_sessions.go index 508c698..ba33b3e 100644 --- a/adapter/http/upload_sessions.go +++ b/adapter/http/upload_sessions.go @@ -1336,3 +1336,19 @@ func (m *uploadSessionManager) handleAbort(w http.ResponseWriter, r *http.Reques } w.WriteHeader(http.StatusNoContent) } + +// writeSlotsInUse reports how many concurrent segment-write slots are held. The +// limit is already published via /v1/capabilities; this is the usage side of it. +func (m *uploadSessionManager) writeSlotsInUse() int { + if m == nil { + return 0 + } + return len(m.writeSlots) +} + +func (m *uploadSessionManager) writeSlotsLimit() int { + if m == nil { + return 0 + } + return cap(m.writeSlots) +} diff --git a/api/v1/system.go b/api/v1/system.go new file mode 100644 index 0000000..c0aaefb --- /dev/null +++ b/api/v1/system.go @@ -0,0 +1,189 @@ +package v1 + +// Types for the operational endpoints: GET /v1/system/info, GET /v1/system/runtime, +// GET /v1/health and GET /v1/uploads/sessions. +// +// The split between info and runtime is deliberate. Info probes the mounts, +// which touches the filesystem, so it is meant to be read occasionally. Runtime +// is made of cheap in-memory counters and is safe to poll for a live dashboard. + +// BuildInfo identifies the running binary. +type BuildInfo struct { + Version string `json:"version"` + Commit string `json:"commit"` + Go string `json:"go"` +} + +// MountInfo reports a configured mount and the outcome of its health probe. +// +// Writable and XAttrSupported are the two properties that silently break +// Filegate when absent: a read-only mount rejects every write, and a mount +// without user xattr support cannot carry stable file IDs. They were previously +// checked once at startup and then discarded. +type MountInfo struct { + Name string `json:"name"` + Path string `json:"path"` + Exists bool `json:"exists"` + Writable bool `json:"writable"` + XAttrSupported bool `json:"xattrSupported"` + FreeBytes uint64 `json:"freeBytes"` + TotalBytes uint64 `json:"totalBytes"` + Errors []string `json:"errors,omitempty"` +} + +// VersioningInfo reports the effective versioning configuration. Enabled is the +// resolved answer, which for the "auto" mode depends on whether the mounts are +// btrfs and was previously only visible in a startup log line. +type VersioningInfo struct { + Enabled bool `json:"enabled"` + Mode string `json:"mode"` + CooldownMs int64 `json:"cooldownMs"` + PrunerIntervalMs int64 `json:"prunerIntervalMs"` + MaxPinnedPerFile int `json:"maxPinnedPerFile"` +} + +// LimitsInfo is the curated, non-secret slice of configuration an operator +// needs to interpret rejections. This is deliberately an allowlist rather than a +// dump of the config file: every field here is one someone chose to publish. +type LimitsInfo struct { + MaxChunkBytes int64 `json:"maxChunkBytes"` + MaxUploadBytes int64 `json:"maxUploadBytes"` + MaxSessionUploadBytes int64 `json:"maxSessionUploadBytes"` + MaxConcurrentSegmentWrites int `json:"maxConcurrentSegmentWrites"` + UploadMinFreeBytes int64 `json:"uploadMinFreeBytes"` + UploadExpiryMs int64 `json:"uploadExpiryMs"` + UploadCleanupIntervalMs int64 `json:"uploadCleanupIntervalMs"` + ThumbnailMaxSourceBytes int64 `json:"thumbnailMaxSourceBytes"` + ThumbnailMaxPixels int64 `json:"thumbnailMaxPixels"` + PathCacheCapacity int `json:"pathCacheCapacity"` + ActivityRingCapacity int `json:"activityRingCapacity"` +} + +// SystemInfoResponse is the body of GET /v1/system/info. +type SystemInfoResponse struct { + GeneratedAt int64 `json:"generatedAt"` + Build BuildInfo `json:"build"` + StartedAt int64 `json:"startedAt"` + UptimeMs int64 `json:"uptimeMs"` + Detector DetectorInfo `json:"detector"` + Versioning VersioningInfo `json:"versioning"` + Limits LimitsInfo `json:"limits"` + Mounts []MountInfo `json:"mounts"` + IndexPath string `json:"indexPath"` +} + +// DetectorInfo is the static half of detector state. +type DetectorInfo struct { + Backend string `json:"backend"` + IntervalMs int64 `json:"intervalMs"` +} + +// DetectorRuntime is the live half: whether detection is actually keeping up. +// +// LastScanAt falling far behind IntervalMs is the signal that the detector +// goroutine died, which otherwise causes silent index drift for writes that did +// not come through the API. +type DetectorRuntime struct { + Backend string `json:"backend"` + IntervalMs int64 `json:"intervalMs"` + Cycles uint64 `json:"cycles"` + LastScanAt int64 `json:"lastScanAt"` + LastScanDurationMs int64 `json:"lastScanDurationMs"` + StaleForMs int64 `json:"staleForMs"` + Errors uint64 `json:"errors"` + PendingBatches int `json:"pendingBatches"` + QueueCapacity int `json:"queueCapacity"` + TrackedDirs int `json:"trackedDirs,omitempty"` + TrackedFiles int `json:"trackedFiles,omitempty"` + Generations map[string]uint64 `json:"generations,omitempty"` +} + +// JobsRuntime reports worker-pool saturation. Queued approaching QueueCapacity +// is what precedes 503 responses from the thumbnail endpoint, which is the only +// consumer of the pool today. +type JobsRuntime struct { + Workers int `json:"workers"` + Queued int `json:"queued"` + QueueCapacity int `json:"queueCapacity"` + InFlight int `json:"inFlight"` + Rejected uint64 `json:"rejected"` + Panics uint64 `json:"panics"` +} + +// CacheRuntime reports occupancy and cumulative effectiveness of one cache. +type CacheRuntime struct { + Entries int `json:"entries"` + Capacity int `json:"capacity"` + Hits uint64 `json:"hits"` + Misses uint64 `json:"misses"` + HitRatio float64 `json:"hitRatio"` +} + +// UploadSessionsRuntime counts resumable upload sessions by phase, plus the +// concurrent segment-write slots currently held. The slot limit is published +// via /v1/capabilities; this is the matching usage figure. +type UploadSessionsRuntime struct { + InProgress int `json:"inProgress"` + Committing int `json:"committing"` + Committed int `json:"committed"` + Aborted int `json:"aborted"` + WriteSlotsInUse int `json:"writeSlotsInUse"` + WriteSlotsLimit int `json:"writeSlotsLimit"` +} + +// SystemRuntimeResponse is the body of GET /v1/system/runtime. Every field is an +// in-memory counter, so this endpoint is safe to poll. +type SystemRuntimeResponse struct { + GeneratedAt int64 `json:"generatedAt"` + Detector DetectorRuntime `json:"detector"` + Jobs JobsRuntime `json:"jobs"` + PathCache CacheRuntime `json:"pathCache"` + ThumbnailCache CacheRuntime `json:"thumbnailCache"` + UploadSessions UploadSessionsRuntime `json:"uploadSessions"` +} + +// HealthCheck is one dependency probe. +type HealthCheck struct { + Name string `json:"name"` + Status string `json:"status"` + Detail string `json:"detail,omitempty"` +} + +// Health status values. Degraded means serving continues but something needs +// attention; fail means the dependency is unusable. +const ( + HealthOK = "ok" + HealthDegraded = "degraded" + HealthFail = "fail" +) + +// HealthResponse is the body of GET /v1/health. Unlike the bare GET /health +// liveness probe, this one actually checks dependencies. +type HealthResponse struct { + Status string `json:"status"` + GeneratedAt int64 `json:"generatedAt"` + Checks []HealthCheck `json:"checks"` +} + +// UploadSessionSummary is one row of GET /v1/uploads/sessions. It omits the +// staging directory and ownership, which are internal placement details. +type UploadSessionSummary struct { + ID string `json:"id"` + Path string `json:"path"` + Size int64 `json:"size"` + SegmentSize int64 `json:"segmentSize"` + TotalSegments int `json:"totalSegments"` + UploadedSegments int `json:"uploadedSegments"` + UploadedBytes int64 `json:"uploadedBytes"` + Phase string `json:"phase"` + CreatedAt int64 `json:"createdAt"` + UpdatedAt int64 `json:"updatedAt"` + AgeMs int64 `json:"ageMs"` + ContentType string `json:"contentType,omitempty"` +} + +// UploadSessionListResponse is the body of GET /v1/uploads/sessions. +type UploadSessionListResponse struct { + Items []UploadSessionSummary `json:"items"` + Total int `json:"total"` +} diff --git a/cli/serve.go b/cli/serve.go index 127303f..fbfb932 100644 --- a/cli/serve.go +++ b/cli/serve.go @@ -186,6 +186,18 @@ func newDaemonServeCmd() *cobra.Command { MetricsPath: cfg.Metrics.Path, MetricsToken: cfg.Metrics.Token, ActivityLog: activityLog, + + BuildVersion: buildVersion, + BuildCommit: buildCommit, + BasePaths: cfg.Storage.BasePaths, + PathCacheSize: cfg.Cache.PathCacheSize, + DetectorStats: detector.Stats, + + VersioningEnabled: versioningEnabled, + VersioningMode: cfg.Versioning.Enabled, + VersioningCooldown: cfg.Versioning.Cooldown, + VersioningPrunerInterval: cfg.Versioning.PrunerInterval, + VersioningMaxPinnedPerFile: cfg.Versioning.MaxPinnedPerFile, }) var routerCloser interface{ Close() error } if closer, ok := router.(interface{ Close() error }); ok { diff --git a/domain/service.go b/domain/service.go index 327135c..7b1dcef 100644 --- a/domain/service.go +++ b/domain/service.go @@ -15,6 +15,7 @@ import ( "strconv" "strings" "sync" + "sync/atomic" "syscall" "time" @@ -54,9 +55,14 @@ type Service struct { cache *lru.Cache[string, pathCacheEntry] idPathCache *lru.Cache[FileID, string] pathCacheSize int - dirSync *coalescedDirSyncer - mu sync.RWMutex - rescanMu sync.Mutex + + // Cumulative path-cache effectiveness. Occupancy alone cannot tell an + // undersized cache from a cold one; the hit ratio can. + pathCacheHits atomic.Uint64 + pathCacheMisses atomic.Uint64 + dirSync *coalescedDirSyncer + mu sync.RWMutex + rescanMu sync.Mutex // Versioning subsystem. EnableVersioning wires these from cli config // after NewService; default-zero means "feature off" so existing @@ -344,7 +350,13 @@ func normalizeVirtualPathInput(virtualPath string) (string, []string, error) { } func (s *Service) resolvePathID(vp string, parts []string) (FileID, error) { - if cached, ok := s.cache.Get(vp); ok { + cached, cacheHit := s.cache.Get(vp) + if cacheHit { + s.pathCacheHits.Add(1) + } else { + s.pathCacheMisses.Add(1) + } + if cacheHit { s.idPathCache.Add(cached.ID, "/"+vp) return cached.ID, nil } @@ -3806,3 +3818,14 @@ func (s *Service) invalidateCacheByID(id FileID) { s.cache.Remove(parent) } } + +// PathCacheStats reports occupancy and cumulative effectiveness of the virtual +// path cache. Hits and misses are cumulative since process start, so a caller +// wanting a rate should sample twice. +func (s *Service) PathCacheStats() (entries, capacity int, hits, misses uint64) { + s.mu.RLock() + entries = s.cache.Len() + capacity = s.pathCacheSize + s.mu.RUnlock() + return entries, capacity, s.pathCacheHits.Load(), s.pathCacheMisses.Load() +} diff --git a/domain/service_upload_session.go b/domain/service_upload_session.go index 0f97aec..40c87ac 100644 --- a/domain/service_upload_session.go +++ b/domain/service_upload_session.go @@ -1,5 +1,7 @@ package domain +import "errors" + func (s *Service) CreateUploadSession(session UploadSession) error { return s.idx.Batch(func(b Batch) error { b.PutUploadSession(session) @@ -59,3 +61,15 @@ func (s *Service) DeleteUploadCommitRecord(sessionID string) error { return nil }) } + +// PingIndex proves the index still answers, using a point lookup on an ID that +// cannot exist. A not-found result is success; anything else means Pebble is +// unhealthy. Cheap enough for a pollable health endpoint, unlike Stats, which +// walks every entity. +func (s *Service) PingIndex() error { + _, err := s.idx.GetEntity(FileID{}) + if err == nil || errors.Is(err, ErrNotFound) { + return nil + } + return err +} From a4c512417ae5b850ca8e4e844c66ad85d8b561bd Mon Sep 17 00:00:00 2001 From: valentinkolb Date: Sat, 25 Jul 2026 23:03:35 +0200 Subject: [PATCH 06/49] feat: expose detector and cache metrics, log auth failures - Prometheus gains filegate_detector_stale_seconds, _cycles_total and _errors_total plus filegate_path_cache_lookups_total{result}. Detector staleness is the signal that detection died and the index is drifting. - Authentication failures now reach the activity log as auth.denied. The activity middleware only records requests with a known actor, so 401s previously left no trace at all. - Go and TS SDKs cover the new system, health and upload-session routes. - docs/http-routes.md and docs/metrics.md document both. Worker-pool gauges are intentionally absent from Prometheus: the scheduler lives inside the router, out of the metrics provider's reach. They are on GET /v1/system/runtime, and metrics.md says so. --- adapter/http/router.go | 27 +++++- cli/metrics_provider.go | 17 ++++ cli/serve.go | 15 +++- docs/http-routes.md | 30 +++++++ docs/metrics.md | 13 +++ infra/metrics/collector.go | 47 +++++++++++ infra/metrics/metrics_test.go | 18 ++++ sdk/filegate/client.go | 2 + sdk/filegate/system.go | 63 ++++++++++++++ sdk/ts/src/client.ts | 3 + sdk/ts/src/index.ts | 20 +++++ sdk/ts/src/system.ts | 43 ++++++++++ sdk/ts/src/types.ts | 154 ++++++++++++++++++++++++++++++++++ 13 files changed, 449 insertions(+), 3 deletions(-) create mode 100644 sdk/filegate/system.go create mode 100644 sdk/ts/src/system.ts diff --git a/adapter/http/router.go b/adapter/http/router.go index a914596..2fabe5d 100644 --- a/adapter/http/router.go +++ b/adapter/http/router.go @@ -194,7 +194,7 @@ func NewRouter(svc *domain.Service, opts RouterOptions) http.Handler { root.HandleFunc("POST /v1/uploads/sessions/{sessionId}/commit", uploadSessions.handleCommit) root.HandleFunc("DELETE /v1/uploads/sessions/{sessionId}", uploadSessions.handleAbort) - auth := authMiddleware(opts.BearerToken) + auth := authMiddleware(opts.BearerToken, opts.ActivityLog) handleV1 := func(pattern string, handler http.HandlerFunc) { root.Handle(pattern, auth(http.HandlerFunc(handler))) } @@ -1880,20 +1880,43 @@ func metricsAuthMiddleware(metricsToken, bearerToken string) func(http.Handler) } } -func authMiddleware(token string) func(http.Handler) http.Handler { +// recordAuthFailure logs a rejected request to the activity ring. +// +// The activity middleware only records requests whose actor could be +// determined, so authentication failures previously left no trace at all -- +// exactly the events an operator investigating an intrusion wants to see. The +// actor is "system" because there is, by definition, no authenticated identity. +func recordAuthFailure(ring *activity.Ring, r *http.Request, reason string) { + if ring == nil { + return + } + ring.Record(activity.Event{ + Actor: activity.Actor{Kind: activity.ActorSystem, ID: "anonymous"}, + Operation: "auth.denied", + Outcome: activity.OutcomeFailed, + Target: &activity.Target{Kind: "path", Path: r.URL.Path}, + RequestID: requestID(r), + Error: reason, + }) +} + +func authMiddleware(token string, ring *activity.Ring) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { auth := strings.TrimSpace(r.Header.Get("Authorization")) if token == "" { + recordAuthFailure(ring, r, "bearer token not configured") writeErr(w, http.StatusUnauthorized, "bearer token not configured") return } if !strings.HasPrefix(auth, "Bearer ") { + recordAuthFailure(ring, r, "missing bearer token") writeErr(w, http.StatusUnauthorized, "missing bearer token") return } provided := strings.TrimPrefix(auth, "Bearer ") if subtle.ConstantTimeCompare([]byte(provided), []byte(token)) != 1 { + recordAuthFailure(ring, r, "invalid bearer token") writeErr(w, http.StatusUnauthorized, "invalid bearer token") return } diff --git a/cli/metrics_provider.go b/cli/metrics_provider.go index 632a2fa..5ba1a2a 100644 --- a/cli/metrics_provider.go +++ b/cli/metrics_provider.go @@ -4,8 +4,10 @@ import ( "os" "path/filepath" "syscall" + "time" "github.com/valentinkolb/filegate/domain" + "github.com/valentinkolb/filegate/infra/detect" "github.com/valentinkolb/filegate/infra/metrics" ) @@ -18,6 +20,10 @@ import ( type metricsStatsProvider struct { svc *domain.Service indexPath string + // detectorStats is set after the detector exists, which happens later in + // startup than the metrics registry. Nil means the detector gauges report + // zero rather than the provider failing the whole scrape. + detectorStats func() detect.Stats } func (p metricsStatsProvider) MetricsSnapshot() (metrics.Snapshot, error) { @@ -25,11 +31,22 @@ func (p metricsStatsProvider) MetricsSnapshot() (metrics.Snapshot, error) { if err != nil { return metrics.Snapshot{}, err } + _, _, cacheHits, cacheMisses := p.svc.PathCacheStats() snap := metrics.Snapshot{ Files: stats.TotalFiles, Dirs: stats.TotalDirs, PathCacheEntries: stats.PathCacheEntries, IndexDBBytes: dirSizeBytesBestEffort(p.indexPath), + PathCacheHits: cacheHits, + PathCacheMisses: cacheMisses, + } + if p.detectorStats != nil { + d := p.detectorStats() + snap.DetectorCycles = d.Cycles + snap.DetectorErrors = d.Errors + if !d.LastScanAt.IsZero() { + snap.DetectorStaleSeconds = time.Since(d.LastScanAt).Seconds() + } } for _, m := range stats.Mounts { abs, rerr := p.svc.ResolveAbsPath(m.ID) diff --git a/cli/serve.go b/cli/serve.go index fbfb932..9bd074e 100644 --- a/cli/serve.go +++ b/cli/serve.go @@ -115,9 +115,21 @@ func newDaemonServeCmd() *cobra.Command { // /metrics endpoint and the per-request middleware are // gated on metrics.enabled. Pass it to the adapters and // loops below. + // The detector is created further down, so the provider reads it + // through this holder rather than capturing a nil value. + var detectorRef detect.Runner metricsReg := metrics.New( metrics.BuildInfo{Version: buildVersion, Commit: buildCommit}, - metricsStatsProvider{svc: svc, indexPath: cfg.Storage.IndexPath}, + metricsStatsProvider{ + svc: svc, + indexPath: cfg.Storage.IndexPath, + detectorStats: func() detect.Stats { + if detectorRef == nil { + return detect.Stats{} + } + return detectorRef.Stats() + }, + }, ) activityLog := activity.NewRing(cfg.Activity.RingBufferSize) @@ -129,6 +141,7 @@ func newDaemonServeCmd() *cobra.Command { _ = idx.Close() return err } + detectorRef = detector log.Printf("[filegate] detection backend: %s", detector.Name()) detector.Start(ctx) detectorDone := make(chan struct{}) diff --git a/docs/http-routes.md b/docs/http-routes.md index a51c36f..8dc3a53 100644 --- a/docs/http-routes.md +++ b/docs/http-routes.md @@ -292,6 +292,36 @@ time and stream the current directory contents when used. - `id` - `ids[]` +## Operations + +These endpoints exist for operators and dashboards. All require the bearer token. + +- `GET /v1/system/info` + - Build version/commit, uptime, detector backend, effective versioning mode, + per-mount health (`exists`, `writable`, `xattrSupported`, free/total bytes), + and a curated set of effective limits. + - Probes the mounts, so it touches the filesystem. Read it occasionally; do + not poll it. +- `GET /v1/system/runtime` + - Live counters only: detector cycles/staleness/errors (plus per-path btrfs + generations), worker-pool queue depth and rejections, path and thumbnail + cache hit ratios, upload sessions by phase, and segment write-slot usage. + - Everything is read from memory, so this is the endpoint to poll. +- `GET /v1/health` + - Dependency checks: index, detector staleness, mount reachability. + - `200` with `status: ok|degraded`, `503` with `status: fail`. + - Distinct from `GET /health`, which stays a plain unauthenticated `OK` + liveness probe and checks nothing. +- `GET /v1/uploads/sessions` + - Lists resumable upload sessions; optional `phase` filter + (`in_progress|committing|committed|aborted`). + - This is how an orphan left by an interrupted upload is found, since + `DELETE /v1/uploads/sessions/{id}` needs an ID that is otherwise unknown. + +Config exposure on `/v1/system/info` is an explicit allowlist of non-secret +operational values, not a dump of the config file. Anything not listed in +`LimitsInfo` stays server-side. + ## Node Shape `Node` returns a discriminated union style via `type` (`file|directory`) with shared metadata: diff --git a/docs/metrics.md b/docs/metrics.md index 8fd5fc9..c75f83a 100644 --- a/docs/metrics.md +++ b/docs/metrics.md @@ -122,6 +122,19 @@ this histogram tells you which without distributed tracing. |--------|------|--------|---------| | `filegate_multipart_complete_phase_seconds` | histogram | phase=concat\|lock_wait\|hash\|pebble_batch | Per-phase Complete duration. | +### Detector and cache + +| Metric | Type | Labels | Meaning | +|--------|------|--------|---------| +| `filegate_detector_stale_seconds` | gauge | — | Seconds since the detector last completed a scan round. Growing far past the scan interval means detection stopped and the index is silently drifting from the filesystem. | +| `filegate_detector_cycles_total` | counter | — | Detection scan rounds completed. | +| `filegate_detector_errors_total` | counter | — | Detection scan errors. | +| `filegate_path_cache_lookups_total` | counter | result=hit\|miss | Path cache lookups by result. Occupancy alone cannot tell an undersized cache from a cold one. | + +Worker-pool saturation is deliberately not a metric: the scheduler lives inside +the HTTP router, which the metrics provider cannot reach without an awkward +back-channel. It is available on `GET /v1/system/runtime` instead. + ### Runtime + process (free, from client_golang) Standard Go-runtime and process collectors are registered: diff --git a/infra/metrics/collector.go b/infra/metrics/collector.go index 5b74efb..66a4125 100644 --- a/infra/metrics/collector.go +++ b/infra/metrics/collector.go @@ -25,6 +25,22 @@ type Snapshot struct { PathCacheEntries int IndexDBBytes int64 Mounts []MountSnapshot + + // PathCacheHits and PathCacheMisses are cumulative since process start. + // Occupancy alone cannot distinguish an undersized cache from a cold one. + PathCacheHits uint64 + PathCacheMisses uint64 + + // Worker-pool saturation is deliberately absent: the scheduler lives + // inside the HTTP router, which this provider cannot reach without an + // awkward back-channel. It is available on GET /v1/system/runtime. + // + // DetectorStaleSeconds is the time since the last completed detection + // round. Growing far past the scan interval means detection stopped and + // the index is silently drifting from the filesystem. + DetectorStaleSeconds float64 + DetectorCycles uint64 + DetectorErrors uint64 } // MountSnapshot is per-mount disk usage. UsedBytes + FreeBytes come @@ -47,6 +63,11 @@ type domainCollector struct { cacheEntr *prometheus.Desc mountUsed *prometheus.Desc // {mount} mountFree *prometheus.Desc // {mount} + + cacheLookups *prometheus.Desc // {result=hit|miss} + detectorStale *prometheus.Desc + detectorCycle *prometheus.Desc + detectorErrs *prometheus.Desc } func newDomainCollector(p StatsProvider) *domainCollector { @@ -72,6 +93,22 @@ func newDomainCollector(p StatsProvider) *domainCollector { "filegate_mount_free_bytes", "Free bytes on the filesystem backing a mount.", []string{"mount"}, nil), + cacheLookups: prometheus.NewDesc( + "filegate_path_cache_lookups_total", + "Path cache lookups by result since process start.", + []string{"result"}, nil), + detectorStale: prometheus.NewDesc( + "filegate_detector_stale_seconds", + "Seconds since the detector last completed a scan round.", + nil, nil), + detectorCycle: prometheus.NewDesc( + "filegate_detector_cycles_total", + "Detection scan rounds completed since process start.", + nil, nil), + detectorErrs: prometheus.NewDesc( + "filegate_detector_errors_total", + "Detection scan errors since process start.", + nil, nil), } } @@ -81,6 +118,10 @@ func (c *domainCollector) Describe(ch chan<- *prometheus.Desc) { ch <- c.cacheEntr ch <- c.mountUsed ch <- c.mountFree + ch <- c.cacheLookups + ch <- c.detectorStale + ch <- c.detectorCycle + ch <- c.detectorErrs } func (c *domainCollector) Collect(ch chan<- prometheus.Metric) { @@ -98,4 +139,10 @@ func (c *domainCollector) Collect(ch chan<- prometheus.Metric) { ch <- prometheus.MustNewConstMetric(c.mountUsed, prometheus.GaugeValue, float64(m.UsedBytes), m.Name) ch <- prometheus.MustNewConstMetric(c.mountFree, prometheus.GaugeValue, float64(m.FreeBytes), m.Name) } + + ch <- prometheus.MustNewConstMetric(c.cacheLookups, prometheus.CounterValue, float64(snap.PathCacheHits), "hit") + ch <- prometheus.MustNewConstMetric(c.cacheLookups, prometheus.CounterValue, float64(snap.PathCacheMisses), "miss") + ch <- prometheus.MustNewConstMetric(c.detectorStale, prometheus.GaugeValue, snap.DetectorStaleSeconds) + ch <- prometheus.MustNewConstMetric(c.detectorCycle, prometheus.CounterValue, float64(snap.DetectorCycles)) + ch <- prometheus.MustNewConstMetric(c.detectorErrs, prometheus.CounterValue, float64(snap.DetectorErrors)) } diff --git a/infra/metrics/metrics_test.go b/infra/metrics/metrics_test.go index 51df3fc..dd669dd 100644 --- a/infra/metrics/metrics_test.go +++ b/infra/metrics/metrics_test.go @@ -272,6 +272,11 @@ func TestDomainCollectorEmitsGauges(t *testing.T) { Mounts: []MountSnapshot{ {Name: "photos", UsedBytes: 1000, FreeBytes: 9000}, }, + PathCacheHits: 900, + PathCacheMisses: 100, + DetectorStaleSeconds: 2.5, + DetectorCycles: 17, + DetectorErrors: 1, }} c := newDomainCollector(p) want := ` @@ -291,6 +296,19 @@ filegate_mount_used_bytes{mount="photos"} 1000 # HELP filegate_mount_free_bytes Free bytes on the filesystem backing a mount. # TYPE filegate_mount_free_bytes gauge filegate_mount_free_bytes{mount="photos"} 9000 +# HELP filegate_path_cache_lookups_total Path cache lookups by result since process start. +# TYPE filegate_path_cache_lookups_total counter +filegate_path_cache_lookups_total{result="hit"} 900 +filegate_path_cache_lookups_total{result="miss"} 100 +# HELP filegate_detector_stale_seconds Seconds since the detector last completed a scan round. +# TYPE filegate_detector_stale_seconds gauge +filegate_detector_stale_seconds 2.5 +# HELP filegate_detector_cycles_total Detection scan rounds completed since process start. +# TYPE filegate_detector_cycles_total counter +filegate_detector_cycles_total 17 +# HELP filegate_detector_errors_total Detection scan errors since process start. +# TYPE filegate_detector_errors_total counter +filegate_detector_errors_total 1 ` if err := testutil.CollectAndCompare(c, strings.NewReader(want)); err != nil { t.Errorf("domain collector mismatch: %v", err) diff --git a/sdk/filegate/client.go b/sdk/filegate/client.go index 4905461..c8e81c8 100644 --- a/sdk/filegate/client.go +++ b/sdk/filegate/client.go @@ -44,6 +44,7 @@ type Filegate struct { Search SearchClient Index IndexClient Stats StatsClient + System SystemClient Capabilities CapabilitiesClient Versions VersionsClient Downloads DownloadsClient @@ -95,6 +96,7 @@ func New(cfg Config) (*Filegate, error) { client.Search = SearchClient{core: core} client.Index = IndexClient{core: core} client.Stats = StatsClient{core: core} + client.System = SystemClient{core: core} client.Capabilities = CapabilitiesClient{core: core} client.Versions = VersionsClient{core: core} client.Downloads = DownloadsClient{core: core} diff --git a/sdk/filegate/system.go b/sdk/filegate/system.go new file mode 100644 index 0000000..ce44059 --- /dev/null +++ b/sdk/filegate/system.go @@ -0,0 +1,63 @@ +package filegate + +import ( + "context" + "net/http" + "net/url" + + apiv1 "github.com/valentinkolb/filegate/api/v1" +) + +// SystemClient contains the operational endpoints. +// +// Info probes the mounts, so read it occasionally. Runtime is made of in-memory +// counters and is the one to poll for a dashboard. +type SystemClient struct { + core *clientCore +} + +// Info returns build details, mount health and effective limits. This touches +// the filesystem to probe mounts and is not meant to be polled. +func (c SystemClient) Info(ctx context.Context) (*apiv1.SystemInfoResponse, error) { + var out apiv1.SystemInfoResponse + if err := c.core.doJSON(ctx, http.MethodGet, "/v1/system/info", nil, nil, "", &out); err != nil { + return nil, err + } + return &out, nil +} + +// Runtime returns live counters for the detector, worker pool, caches and +// upload sessions. Cheap enough to poll. +func (c SystemClient) Runtime(ctx context.Context) (*apiv1.SystemRuntimeResponse, error) { + var out apiv1.SystemRuntimeResponse + if err := c.core.doJSON(ctx, http.MethodGet, "/v1/system/runtime", nil, nil, "", &out); err != nil { + return nil, err + } + return &out, nil +} + +// Health runs the dependency checks. The server answers 503 when a check fails, +// which surfaces here as an error; use the returned body from a raw call if the +// individual check results matter in that case. +func (c SystemClient) Health(ctx context.Context) (*apiv1.HealthResponse, error) { + var out apiv1.HealthResponse + if err := c.core.doJSON(ctx, http.MethodGet, "/v1/health", nil, nil, "", &out); err != nil { + return nil, err + } + return &out, nil +} + +// UploadSessions lists resumable upload sessions. An empty phase lists all of +// them; this is how an orphaned session left by an interrupted upload is found, +// since aborting one needs an ID that is otherwise no longer known. +func (c SystemClient) UploadSessions(ctx context.Context, phase string) (*apiv1.UploadSessionListResponse, error) { + path := "/v1/uploads/sessions" + if phase != "" { + path += "?phase=" + url.QueryEscape(phase) + } + var out apiv1.UploadSessionListResponse + if err := c.core.doJSON(ctx, http.MethodGet, path, nil, nil, "", &out); err != nil { + return nil, err + } + return &out, nil +} diff --git a/sdk/ts/src/client.ts b/sdk/ts/src/client.ts index ff58eaf..824f6ae 100644 --- a/sdk/ts/src/client.ts +++ b/sdk/ts/src/client.ts @@ -7,6 +7,7 @@ import { NodesClient } from "./nodes.js"; import { PathsClient } from "./paths.js"; import { SearchClient } from "./search.js"; import { StatsClient } from "./stats.js"; +import { SystemClient } from "./system.js"; import { TransfersClient } from "./transfers.js"; import { UploadsClient } from "./uploads.js"; import { VersionsClient } from "./versions.js"; @@ -30,6 +31,7 @@ export class Filegate { readonly search: SearchClient; readonly index: IndexClient; readonly stats: StatsClient; + readonly system: SystemClient; readonly capabilities: CapabilitiesClient; readonly versions: VersionsClient; readonly downloads: DownloadsClient; @@ -57,6 +59,7 @@ export class Filegate { this.search = new SearchClient(core); this.index = new IndexClient(core); this.stats = new StatsClient(core); + this.system = new SystemClient(core); this.capabilities = new CapabilitiesClient(core); this.versions = new VersionsClient(core); this.downloads = new DownloadsClient(core); diff --git a/sdk/ts/src/index.ts b/sdk/ts/src/index.ts index 3163786..7a69a69 100644 --- a/sdk/ts/src/index.ts +++ b/sdk/ts/src/index.ts @@ -46,6 +46,26 @@ export type { export type { FileConflictMode, FingerprintMode, MkdirConflictMode, UploadSessionConflictMode } from "./types.js"; +export type { + BuildInfo, + CacheRuntime, + DetectorInfo, + DetectorRuntime, + HealthCheck, + HealthResponse, + HealthStatus, + JobsRuntime, + LimitsInfo, + MountInfo, + SystemInfoResponse, + SystemRuntimeResponse, + UploadSessionListResponse, + UploadSessionPhase, + UploadSessionSummary, + UploadSessionsRuntime, + VersioningInfo, +} from "./types.js"; + // Pure helpers are intentionally NOT re-exported here. Import them from the // dedicated entrypoint to keep tree-shaking honest: // import { uploads } from "@valentinkolb/filegate/utils"; diff --git a/sdk/ts/src/system.ts b/sdk/ts/src/system.ts new file mode 100644 index 0000000..8f6e7ff --- /dev/null +++ b/sdk/ts/src/system.ts @@ -0,0 +1,43 @@ +import { ClientCore } from "./core.js"; +import type { + HealthResponse, + SystemInfoResponse, + SystemRuntimeResponse, + UploadSessionListResponse, + UploadSessionPhase, +} from "./types.js"; + +/** + * Operational endpoints. + * + * `info` probes the mounts, so read it occasionally. `runtime` is made of + * in-memory counters and is the one to poll for a live dashboard. + */ +export class SystemClient { + constructor(private readonly core: ClientCore) {} + + /** Build, mounts and effective limits. Touches the filesystem; not for polling. */ + async info(): Promise { + return this.core.doJSON("GET", "/v1/system/info"); + } + + /** Live counters: detector, worker pool, caches, upload sessions. Safe to poll. */ + async runtime(): Promise { + return this.core.doJSON("GET", "/v1/system/runtime"); + } + + /** + * Dependency health. Unlike the bare /health liveness probe this checks the + * index, detector and mounts, and the server answers 503 when a check fails, + * so callers that treat non-2xx as an error still get the body. + */ + async health(): Promise { + return this.core.doJSON("GET", "/v1/health"); + } + + /** Resumable upload sessions, optionally filtered by phase. */ + async uploadSessions(options: { phase?: UploadSessionPhase } = {}): Promise { + const query = options.phase ? `?phase=${encodeURIComponent(options.phase)}` : ""; + return this.core.doJSON("GET", `/v1/uploads/sessions${query}`); + } +} diff --git a/sdk/ts/src/types.ts b/sdk/ts/src/types.ts index da6b5c4..19ea0d1 100644 --- a/sdk/ts/src/types.ts +++ b/sdk/ts/src/types.ts @@ -347,3 +347,157 @@ export interface IndexResolveManyResponse { items: (Node | null)[]; total: number; } + +/** Phase of a resumable upload session. */ +export type UploadSessionPhase = "in_progress" | "committing" | "committed" | "aborted"; + +export interface BuildInfo { + version: string; + commit: string; + go: string; +} + +/** + * A configured mount and the result of its health probe. `writable` and + * `xattrSupported` are the two properties whose absence breaks Filegate + * silently: no writes, and no stable file IDs. + */ +export interface MountInfo { + name: string; + path: string; + exists: boolean; + writable: boolean; + xattrSupported: boolean; + freeBytes: number; + totalBytes: number; + errors?: string[]; +} + +export interface VersioningInfo { + enabled: boolean; + mode: string; + cooldownMs: number; + prunerIntervalMs: number; + maxPinnedPerFile: number; +} + +/** Curated, non-secret configuration needed to interpret server rejections. */ +export interface LimitsInfo { + maxChunkBytes: number; + maxUploadBytes: number; + maxSessionUploadBytes: number; + maxConcurrentSegmentWrites: number; + uploadMinFreeBytes: number; + uploadExpiryMs: number; + uploadCleanupIntervalMs: number; + thumbnailMaxSourceBytes: number; + thumbnailMaxPixels: number; + pathCacheCapacity: number; + activityRingCapacity: number; +} + +export interface DetectorInfo { + backend: string; + intervalMs: number; +} + +export interface SystemInfoResponse { + generatedAt: number; + build: BuildInfo; + startedAt: number; + uptimeMs: number; + detector: DetectorInfo; + versioning: VersioningInfo; + limits: LimitsInfo; + mounts: MountInfo[]; + indexPath: string; +} + +/** + * Live detector state. `staleForMs` growing far past `intervalMs` means + * detection stopped, which causes silent index drift rather than an outage. + */ +export interface DetectorRuntime { + backend: string; + intervalMs: number; + cycles: number; + lastScanAt: number; + lastScanDurationMs: number; + staleForMs: number; + errors: number; + pendingBatches: number; + queueCapacity: number; + trackedDirs?: number; + trackedFiles?: number; + generations?: Record; +} + +/** Worker pool pressure. `queued` nearing `queueCapacity` precedes 503s. */ +export interface JobsRuntime { + workers: number; + queued: number; + queueCapacity: number; + inFlight: number; + rejected: number; + panics: number; +} + +export interface CacheRuntime { + entries: number; + capacity: number; + hits: number; + misses: number; + hitRatio: number; +} + +export interface UploadSessionsRuntime { + inProgress: number; + committing: number; + committed: number; + aborted: number; + writeSlotsInUse: number; + writeSlotsLimit: number; +} + +export interface SystemRuntimeResponse { + generatedAt: number; + detector: DetectorRuntime; + jobs: JobsRuntime; + pathCache: CacheRuntime; + thumbnailCache: CacheRuntime; + uploadSessions: UploadSessionsRuntime; +} + +export type HealthStatus = "ok" | "degraded" | "fail"; + +export interface HealthCheck { + name: string; + status: HealthStatus; + detail?: string; +} + +export interface HealthResponse { + status: HealthStatus; + generatedAt: number; + checks: HealthCheck[]; +} + +export interface UploadSessionSummary { + id: string; + path: string; + size: number; + segmentSize: number; + totalSegments: number; + uploadedSegments: number; + uploadedBytes: number; + phase: UploadSessionPhase; + createdAt: number; + updatedAt: number; + ageMs: number; + contentType?: string; +} + +export interface UploadSessionListResponse { + items: UploadSessionSummary[]; + total: number; +} From 2b5b37b55bd50dccff95588b9e0e7fb744881963 Mon Sep 17 00:00:00 2001 From: valentinkolb Date: Sat, 25 Jul 2026 23:11:25 +0200 Subject: [PATCH 07/49] feat(admin): name the signed-in admin in Filegate audit entries Filegate attributes every audit entry to the bearer token, so with one shared token several admins were indistinguishable in the log. It already accepted X-Filegate-Actor and recorded it as delegatedActor; nothing sent it. The admin now sends the session label on every upstream request. The actor lives in async-local storage rather than a parameter because client() is called from route handlers and from load helpers several frames deep, and threading an argument through all of them would touch every call site to move one string. The System page needed no change: actorName already prefers delegatedActor over the credential id. --- admin/src/app.tsx | 2 ++ admin/src/lib/actor.ts | 31 +++++++++++++++++ admin/src/lib/filegate.ts | 5 +++ admin/test/actor.test.ts | 72 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 110 insertions(+) create mode 100644 admin/src/lib/actor.ts create mode 100644 admin/test/actor.test.ts diff --git a/admin/src/app.tsx b/admin/src/app.tsx index b0c55e3..71445b1 100644 --- a/admin/src/app.tsx +++ b/admin/src/app.tsx @@ -17,6 +17,7 @@ import { type UploadSessionDirectRequest, } from "@valentinkolb/filegate"; import { Hono } from "hono"; +import { withActor } from "./lib/actor"; import { authMethods, login, logout, oidcBegin, oidcCallback, requireAuth } from "./lib/auth"; import { client, isList, parentPath, resolveDirectory } from "./lib/filegate"; import { env } from "./lib/env"; @@ -73,6 +74,7 @@ export const app = new Hono() .get("/auth/login", oidcBegin) .get("/auth/callback", oidcCallback) .use("*", requireAuth()) + .use("*", withActor()) .post("/logout", logout) .get( "/", diff --git a/admin/src/lib/actor.ts b/admin/src/lib/actor.ts new file mode 100644 index 0000000..0d6a939 --- /dev/null +++ b/admin/src/lib/actor.ts @@ -0,0 +1,31 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import type { MiddlewareHandler } from "hono"; +import { currentSession } from "./auth"; + +/** + * Request-scoped identity of the signed-in admin. + * + * Filegate attributes every audit entry to the bearer token, so with a single + * shared token several admins are indistinguishable in the log. It already + * accepts an X-Filegate-Actor header and records it as delegatedActor, which is + * what this carries. + * + * Async-local rather than a parameter because client() is called from route + * handlers and from load helpers several frames deep; threading an actor + * argument through all of them would touch every call site to move one string. + */ +const store = new AsyncLocalStorage(); + +/** Label of the admin behind the current request, if any. */ +export function currentActor(): string | undefined { + return store.getStore(); +} + +/** Runs authenticated requests with the session label available to client(). */ +export function withActor(): MiddlewareHandler { + return async (c, next) => { + const session = currentSession(c); + if (!session) return next(); + return store.run(session.label, next); + }; +} diff --git a/admin/src/lib/filegate.ts b/admin/src/lib/filegate.ts index 6095bce..7fb030c 100644 --- a/admin/src/lib/filegate.ts +++ b/admin/src/lib/filegate.ts @@ -1,12 +1,17 @@ import { Filegate, type Node, type NodeListResponse } from "@valentinkolb/filegate"; +import { currentActor } from "./actor"; import { env } from "./env"; export function client(): Filegate { const cfg = env(); + const actor = currentActor(); return new Filegate({ baseUrl: cfg.filegateUrl, token: cfg.filegateToken, userAgent: "filegate-admin/0", + // Names the human in Filegate's audit log instead of the shared token. + // Filegate sanitizes and truncates the value on its side. + defaultHeaders: actor ? { "X-Filegate-Actor": actor } : undefined, }); } diff --git a/admin/test/actor.test.ts b/admin/test/actor.test.ts new file mode 100644 index 0000000..8c9e8bd --- /dev/null +++ b/admin/test/actor.test.ts @@ -0,0 +1,72 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import type { Hono } from "hono"; + +/** + * Filegate attributes audit entries to the bearer token, so several admins + * sharing one token are indistinguishable. These tests assert the admin sends + * X-Filegate-Actor so the log names the human instead. + */ +let app: Hono; +let issueSession: typeof import("../src/lib/session").issueSession; +const seen: { actor?: string | null; path: string }[] = []; + +beforeAll(async () => { + // Stand-in Filegate that records the actor header of every inbound request. + const filegate = Bun.serve({ + port: 0, + fetch: (req) => { + const url = new URL(req.url); + seen.push({ actor: req.headers.get("x-filegate-actor"), path: url.pathname }); + if (url.pathname === "/v1/stats") { + return Response.json({ + generatedAt: 0, + index: { totalEntities: 0, totalFiles: 0, totalDirs: 0, dbSizeBytes: 0 }, + cache: { pathEntries: 0, pathCapacity: 0, pathUtilRatio: 0 }, + mounts: [], + disks: [], + system: { goroutines: 0, heapAllocBytes: 0, heapSysBytes: 0, heapObjects: 0, numGC: 0, lastGCPauseNs: 0, openFDs: 0, maxFDs: 0 }, + }); + } + return Response.json({ items: [], total: 0 }); + }, + }); + + Bun.env.FILEGATE_URL = `http://127.0.0.1:${filegate.port}`; + Bun.env.FILEGATE_TOKEN = "filegate-token"; + Bun.env.ADMIN_TOKEN = "admin-token"; + Bun.env.ADMIN_SESSION_SECRET = "session-secret"; + delete Bun.env.REDIS_URL; + + app = (await import("../src/app")).app; + issueSession = (await import("../src/lib/session")).issueSession; +}); + +function requestAs(label: string, path = "/"): Promise { + const { value } = issueSession({ sub: "user-1", label, kind: "oidc" }); + return app.fetch( + new Request(`http://localhost${path}`, { + headers: { host: "localhost", cookie: `filegate_admin=${value}` }, + redirect: "manual", + }), + ); +} + +describe("actor propagation", () => { + test("names the signed-in human on upstream requests", async () => { + seen.length = 0; + await requestAs("ada@example.com"); + + expect(seen.length).toBeGreaterThan(0); + expect(seen.every((call) => call.actor === "ada@example.com")).toBe(true); + }); + + test("keeps concurrent requests from different admins apart", async () => { + // The actor lives in async-local storage, so overlapping requests must not + // leak each other's identity. + seen.length = 0; + await Promise.all([requestAs("ada@example.com"), requestAs("grace@example.com")]); + + const actors = new Set(seen.map((call) => call.actor)); + expect(actors).toEqual(new Set(["ada@example.com", "grace@example.com"])); + }); +}); From bbc2b7504e8f4a19538c4942e1a2b6fff3b88dfa Mon Sep 17 00:00:00 2001 From: valentinkolb Date: Sat, 25 Jul 2026 23:56:27 +0200 Subject: [PATCH 08/49] fix(admin): stop the UI misleading operators Six defects where the interface reported something untrue. - Directory listings silently truncated at 100 entries and the item counter reported the truncated number as the total. The server has cursor pagination; the UI took page one and rendered it as the whole folder. Now follows the cursor, with a 5000-entry cap that is labelled when it bites rather than hidden. - The topbar health dot was hardcoded green and stayed green through a total outage. It now reflects GET /v1/health: Healthy, Degraded, Unreachable, or Unknown. - /search and /system had no error handling at all, so a Filegate outage produced a raw 500 instead of the error banner the other pages show. - Failed delete, rename, metadata and transfer threw the user back to the mount roots, losing their place. They now return to the folder they were working in. - Index rescan was fire-and-forget with console.error, so "rescan started" appeared even when the call failed. It now awaits and reports the actual outcome. - Search declared an error prop that was never passed, so glob failures were invisible. Verified against the running stack: a folder of 250 files renders all 250 with a correct counter, and with Filegate stopped all four pages return 200 with an error banner and the dot reads Unreachable. --- admin/src/app.tsx | 117 +++++++++++++++++++++++++------- admin/src/components/Layout.tsx | 28 +++++++- admin/src/pages/Files.tsx | 13 ++++ admin/src/pages/Overview.tsx | 3 +- admin/src/pages/Search.tsx | 3 +- admin/src/pages/System.tsx | 2 + admin/src/prompts.ts | 11 ++- admin/src/styles.css | 1 + 8 files changed, 148 insertions(+), 30 deletions(-) diff --git a/admin/src/app.tsx b/admin/src/app.tsx index 71445b1..9f116f2 100644 --- a/admin/src/app.tsx +++ b/admin/src/app.tsx @@ -6,6 +6,8 @@ import { type BrowserUploadAllowResult, type BrowserUploadConflictMode, type CapabilitiesResponse, + type GlobSearchResponse, + type HealthStatus, type DirectUploadURLResponse, type FileConflictMode, type MkdirConflictMode, @@ -81,7 +83,7 @@ export const app = new Hono() ...ssr(async (c) => { setPage(c, "Overview"); const data = await loadBase(); - return () => ; + return () => ; }), ) .get( @@ -129,7 +131,7 @@ export const app = new Hono() await client().nodes.delete(node.id); return c.redirect(redirectFiles(parentPath(node.path)), 303); } catch (err) { - return c.redirect(redirectFiles("", errorMessage(err)), 303); + return c.redirect(redirectFiles(field(body, "parentPath"), errorMessage(err)), 303); } }) .get("/files/download", async (c) => { @@ -142,7 +144,7 @@ export const app = new Hono() }); return c.redirect(out.downloadUrl, 303); } catch (err) { - return c.redirect(redirectFiles("", errorMessage(err)), 303); + return c.redirect(redirectFiles(c.req.query("parentPath") || "", errorMessage(err)), 303); } }) .post("/files/rename", async (c) => { @@ -151,7 +153,7 @@ export const app = new Hono() const updated = await client().nodes.patch(field(body, "id"), { name: field(body, "name") }); return c.redirect(selectedFiles(parentPath(updated.path), updated.id), 303); } catch (err) { - return c.redirect(redirectFiles("", errorMessage(err)), 303); + return c.redirect(selectedFiles(field(body, "parentPath"), field(body, "id"), errorMessage(err)), 303); } }) .post("/files/metadata", async (c) => { @@ -160,7 +162,7 @@ export const app = new Hono() const updated = await client().nodes.patch(field(body, "id"), { ownership: ownershipFromForm(body) }, field(body, "recursiveOwnership") === "true"); return c.redirect(selectedFiles(parentPath(updated.path), updated.id), 303); } catch (err) { - return c.redirect(redirectFiles("", errorMessage(err)), 303); + return c.redirect(selectedFiles(field(body, "parentPath"), field(body, "id"), errorMessage(err)), 303); } }) .post("/files/transfer", async (c) => { @@ -176,20 +178,28 @@ export const app = new Hono() }); return c.redirect(selectedFiles(parentPath(out.node.path), out.node.id), 303); } catch (err) { - return c.redirect(redirectFiles("", errorMessage(err)), 303); + return c.redirect(selectedFiles(field(body, "parentPath"), field(body, "id"), errorMessage(err)), 303); } }) .get( "/search", ...ssr(async (c) => { setPage(c, "Search"); - const stats = await loadStats(); + const base = await loadBase(); const pattern = c.req.query("pattern") || ""; const hidden = c.req.query("hidden") === "true"; - const results = pattern - ? await client().search.glob({ pattern, limit: 100, showHidden: hidden, files: true, directories: true }) - : undefined; - return () =>