/session and /_web/* were both dead. createSidecarProxy registers all('/*')
when it is CALLED, so anything added to proxy.router afterwards never matches —
hono goes in registration order and the wildcard had already claimed
everything.
The symptom was `[gitea] proxy POST /session -> 500`: forwarded to a sidecar
with no such route, answered "internal error", and looking exactly like the
sidecar being broken. Which is what the panel showed.
A comment two lines above the bug claimed these were "registered BEFORE the
proxy's /*". They were not, and writing that down did not make it so.
Now a router of our own with the proxy's mounted under it at the end, so
everything above genuinely wins.
154 lines
6.9 KiB
TypeScript
154 lines
6.9 KiB
TypeScript
import { randomBytes } from 'node:crypto';
|
|
import { createRouter } from '@@/create-router';
|
|
import { createSidecarProxy } from '@@/sidecar/create-proxy';
|
|
import { mountPrefix } from '@@/plugins/manifest';
|
|
import { manifest } from '../manifest';
|
|
|
|
// Two doors, because an iframe cannot carry a bearer token.
|
|
//
|
|
// ── The ordinary door ──
|
|
//
|
|
// `/_config`, `/_health` and `/_api/*` are normal API calls from the panel. They go through the
|
|
// platform's gate like any plugin's, and `createSidecarProxy` forwards them.
|
|
//
|
|
// ── The iframe door ──
|
|
//
|
|
// Gitea already has a web interface, and this plugin renders it rather than reimplementing it. That
|
|
// means an `<iframe>`, and an iframe's sub-resources — every stylesheet, script and avatar — are fetched
|
|
// by the browser with no Authorization header. So `/_web/*` is served ABOVE the platform's gate
|
|
// (`unauthenticatedPaths` in the manifest) and authenticates itself with a cookie instead.
|
|
//
|
|
// The cookie holds a MINTED token, not a constant. `dev-proxy` sets `=1`, which is fine for a
|
|
// tailnet-only owner-gated preview but means anyone who knows the cookie name can set it themselves.
|
|
// Here the value is 32 random bytes, held in memory against the user who asked for it, and it expires.
|
|
//
|
|
// What it protects is narrow, and worth being honest about: Gitea authenticates the person itself, with
|
|
// its own session cookie, so an unauthenticated caller reaching `/_web/` sees Gitea's login page and
|
|
// nothing else. This stops Officer being used as an open proxy to it — not a data leak, a manners
|
|
// problem, but a cheap one to fix.
|
|
|
|
const PREFIX = `/api${mountPrefix({ appName: 'gitea', manifest })}`;
|
|
|
|
const proxy = createSidecarProxy({ name: 'gitea', prefix: PREFIX });
|
|
|
|
/**
|
|
* OUR routes first, then the proxy's catch-all.
|
|
*
|
|
* `createSidecarProxy` registers `all('/*')` when it is CALLED, so anything added to `proxy.router`
|
|
* afterwards is dead — hono matches in registration order and the wildcard has already claimed
|
|
* everything. Both routes below were added that way at first, and the symptom was
|
|
* `POST /session -> 500`: forwarded to a sidecar that has no such route, reported as "internal error",
|
|
* and looking for all the world like the sidecar being broken.
|
|
*
|
|
* So this is a router of our own, with the proxy's mounted UNDER it at the end.
|
|
*/
|
|
const router_ = createRouter();
|
|
|
|
/** Mint the frame cookie for the iframe, which cannot carry a bearer token. */
|
|
router_.post('/session', (ctx) => {
|
|
const user = ctx.get('user') as { id: number } | undefined;
|
|
if (!user) return ctx.json({ error: 'not authenticated' }, 401);
|
|
ctx.header('set-cookie', mintFrameCookie(user.id, PREFIX));
|
|
return ctx.json({ ok: true });
|
|
});
|
|
|
|
/**
|
|
* The iframe door, forwarded by hand rather than through `createSidecarProxy`.
|
|
*
|
|
* That proxy forwards exactly three headers — `content-type`, `range`, `if-none-match` — and nothing
|
|
* else, on purpose: it is the shared forwarder for every sidecar and its restraint is what makes it safe
|
|
* to reuse. **It strips cookies**, and this path is nothing but cookies. Loosening it for gitea would
|
|
* loosen it for the wallet.
|
|
*
|
|
* So this one path forwards the browser's request itself, headers and all. Everything else about the
|
|
* plugin still goes through the shared proxy.
|
|
*/
|
|
router_.all('/_web/*', async (ctx) => {
|
|
const userId = frameUser(ctx.req.header('cookie') ?? null);
|
|
// Deliberately terse. Whoever reaches this without a cookie is a stray request, not a person — the
|
|
// panel mints one before the frame loads.
|
|
if (userId === null) return ctx.text('no gitea frame session', 401);
|
|
|
|
const sidecar = proxy.getHttpUrl();
|
|
if (!sidecar) return ctx.text('gitea sidecar not available', 503);
|
|
|
|
const url = new URL(ctx.req.url);
|
|
const subpath = url.pathname.slice(PREFIX.length); // keeps the `/_web` the sidecar routes on
|
|
|
|
const headers = new Headers(ctx.req.raw.headers);
|
|
for (const h of ['host', 'connection', 'keep-alive', 'transfer-encoding', 'upgrade', 'authorization']) {
|
|
headers.delete(h);
|
|
}
|
|
// The sidecar binds loopback and trusts these, which is exactly why nothing but this may set them.
|
|
headers.set('X-Officer-User', String(userId));
|
|
// Where the browser thinks the frame lives. The sidecar rewrites Gitea's HTML, redirects and cookies
|
|
// against it and has no other way to know — it must not hardcode how the platform routes to it, and
|
|
// `mountPrefix` is the only thing entitled to decide that.
|
|
headers.set('X-Officer-Frame-Base', `${PREFIX}/_web`);
|
|
|
|
const method = ctx.req.method;
|
|
const hasBody = method !== 'GET' && method !== 'HEAD';
|
|
try {
|
|
const upstream = await fetch(`${sidecar}${subpath}${url.search}`, {
|
|
method,
|
|
headers,
|
|
...(hasBody ? { body: await ctx.req.raw.arrayBuffer() } : {}),
|
|
// The sidecar has already rewritten any Location for the frame; following it here would fetch a
|
|
// page the browser never asked for and lose the redirect it needs to see.
|
|
redirect: 'manual',
|
|
});
|
|
return new Response(upstream.body, { status: upstream.status, headers: upstream.headers });
|
|
} catch {
|
|
return ctx.text('gitea unreachable', 502);
|
|
}
|
|
});
|
|
|
|
// Last, so everything above wins.
|
|
router_.route('/', proxy.router);
|
|
|
|
export const router = router_;
|
|
|
|
/** Base URL of the sidecar's HTTP server, or null if it has not reported in yet. */
|
|
export const getGiteaServerUrl = proxy.getHttpUrl;
|
|
|
|
// ── The cookie ──
|
|
|
|
const COOKIE = 'officer_gitea_frame';
|
|
const TTL_MS = 12 * 60 * 60 * 1000;
|
|
|
|
/** token → { userId, expires }. In memory on purpose: a restart closing every frame is correct. */
|
|
const sessions = new Map<string, { userId: number; expires: number }>();
|
|
|
|
const sweep = () => {
|
|
const now = Date.now();
|
|
for (const [token, s] of sessions) if (s.expires < now) sessions.delete(token);
|
|
};
|
|
|
|
/** Mint a frame token for an authenticated caller, and the Set-Cookie that carries it. */
|
|
export function mintFrameCookie(userId: number, prefix: string): string {
|
|
sweep();
|
|
const token = randomBytes(32).toString('base64url');
|
|
sessions.set(token, { userId, expires: Date.now() + TTL_MS });
|
|
// Path-scoped to the frame, so it is never sent to the rest of the platform. `Lax` rather than `None`
|
|
// because the frame is same-origin — which is the entire reason this is proxied instead of pointed at
|
|
// gitea.officer.dev, where the cookie would be third-party and dropped.
|
|
return `${COOKIE}=${token}; Path=${prefix}/_web; Max-Age=${Math.floor(TTL_MS / 1000)}; HttpOnly; SameSite=Lax`;
|
|
}
|
|
|
|
/** The user this request's frame cookie belongs to, or null. */
|
|
export function frameUser(cookieHeader: string | null): number | null {
|
|
if (!cookieHeader) return null;
|
|
const raw = cookieHeader
|
|
.split(';')
|
|
.map((p) => p.trim())
|
|
.find((p) => p.startsWith(`${COOKIE}=`));
|
|
if (!raw) return null;
|
|
const found = sessions.get(raw.slice(COOKIE.length + 1));
|
|
if (!found) return null;
|
|
if (found.expires < Date.now()) {
|
|
sessions.delete(raw.slice(COOKIE.length + 1));
|
|
return null;
|
|
}
|
|
return found.userId;
|
|
}
|