diff --git a/api/router.ts b/api/router.ts new file mode 100644 index 0000000..cdb3f45 --- /dev/null +++ b/api/router.ts @@ -0,0 +1,136 @@ +import { createRouter } from '@@/create-router'; +import { createSidecarProxy } from '@@/sidecar/create-proxy'; +import { getLinkwardenConfig, setLinkwardenConfig, getAccount } from '../db/queries'; + +/** + * Mounted at `/api/linkwarden`. + * + * Three things live here and they are worth telling apart, because two of them look the same from the + * outside and are not: + * + * `/lw/*` the extension's door. Unauthenticated, forwarded verbatim, credential is the caller's. + * `/api/*` OUR door. Officer session required; we swap in the caller's Linkwarden token. + * `/config` where the instance is. Owner only, by the manifest. + */ +export const router = createRouter(); + +/* + The extension's door. + + `proxiedSubPath` is the load-bearing argument. `registerProxiedPrefix` tells the body parser to leave a + prefix alone — right for forwarded traffic, and required for uploads — but this plugin serves its own + routes under the same prefix, and those need their bodies parsed. Claiming the whole of + `/api/linkwarden` would make every field on `/config` read as missing, which is the failure caldav hit + on the day it was extracted. So the proxy claims `/lw` and nothing else. +*/ +const proxy = createSidecarProxy({ + name: 'linkwarden', + prefix: '/api/linkwarden', + proxiedSubPath: '/lw', + permissionKey: 'linkwarden', + // Archiving a page can take a while, and the extension waits on it. + timeoutSeconds: 120, +}); + +router.route('/lw', proxy.router); + +// ── where the instance is ──────────────────────────────────────────────────────────────────────── + +router.get('/config', async (ctx) => { + const config = await getLinkwardenConfig(); + // The admin token is never returned, only whether one is set — see `getAdminToken` for why it is read + // through a separate function rather than being a field on this. + return ctx.json({ url: config.url, enabled: config.enabled, updatedAt: config.updatedAt }); +}); + +router.put('/config', async (ctx) => { + const body = (await ctx.req.json().catch(() => null)) as { + url?: string | null; + enabled?: boolean; + adminToken?: string | null; + } | null; + if (!body) return ctx.json({ error: 'expected a JSON object' }, 400); + + if (body.url !== undefined && body.url !== null && !/^https?:\/\//i.test(body.url.trim())) { + return ctx.json({ error: 'the address must start with http:// or https://' }, 400); + } + + const config = await setLinkwardenConfig(body); + return ctx.json({ url: config.url, enabled: config.enabled, updatedAt: config.updatedAt }); +}); + +/** + * Can we actually reach it, and does the credential work? + * + * Separate from `/config` because "the owner typed an address" and "there is a Linkwarden there" are + * different facts, and a settings panel that conflates them makes a typo look like an outage. Asked of + * the sidecar rather than from here: it is the thing that will have to reach it, and its answer is the + * one that matters. + */ +router.get('/config/health', async (ctx) => { + const base = proxy.getHttpUrl(); + if (!base) return ctx.json({ ok: false, reason: 'sidecar not running' }, 503); + try { + const probe = await fetch(`${base}/_health`, { signal: AbortSignal.timeout(8000) }); + return ctx.json(await probe.json(), probe.status as 200); + } catch { + return ctx.json({ ok: false, reason: 'sidecar unreachable' }, 502); + } +}); + +// ── our own door ───────────────────────────────────────────────────────────────────────────────── + +/** + * Linkwarden's API, as the calling Officer user. + * + * ── Why this exists next to `/lw`, which already forwards ── + * + * `/lw` forwards the caller's own credential, which is right for the extension and impossible for us: a + * browser holding an Officer session has no Linkwarden token and must never be given one. So this route + * looks up who is calling, finds their mapped account, and puts THEIR token on the request. The device + * holds an Officer session and nothing else, which is the same property the Vault plugin exists to give. + * + * The permission model stays Linkwarden's. Officer decides whose token goes on; Linkwarden decides what + * that token may see. Re-implementing the second half here would be a second answer able to disagree + * with the first. + */ +router.all('/api/*', async (ctx) => { + const userId = ctx.get('user')?.id; + if (!userId) return ctx.json({ error: 'unauthenticated' }, 401); + + const base = proxy.getHttpUrl(); + if (!base) return ctx.json({ error: 'the Linkwarden sidecar is not running' }, 503); + + const account = await getAccount(userId); + if (!account) { + // Named rather than a bare 403: "you have no Linkwarden account" is something the owner can fix, and + // a message that says so is the difference between a support question and a self-service one. + return ctx.json({ error: 'no Linkwarden account is mapped to you — ask the owner to create one' }, 403); + } + if (!account.accessToken) { + return ctx.json({ error: 'your Linkwarden account has no access token yet' }, 403); + } + + const url = new URL(ctx.req.url); + // `/api/linkwarden/api/v1/links` is Linkwarden's `/api/v1/links`. + const subpath = url.pathname.slice('/api/linkwarden'.length) || '/'; + + const headers = new Headers(ctx.req.raw.headers); + headers.delete('cookie'); // an Officer session cookie means nothing upstream and should not travel + headers.set('Authorization', `Bearer ${account.accessToken}`); + + const hasBody = ctx.req.method !== 'GET' && ctx.req.method !== 'HEAD'; + const init: RequestInit & { duplex?: 'half' } = { + method: ctx.req.method, + headers, + redirect: 'manual', + ...(hasBody ? { body: ctx.req.raw.body, duplex: 'half' as const } : {}), + }; + + try { + const upstream = await fetch(`${base}${subpath}${url.search}`, init); + return new Response(upstream.body, { status: upstream.status, headers: upstream.headers }); + } catch { + return ctx.json({ error: 'Linkwarden unreachable' }, 502); + } +}); diff --git a/db/queries.ts b/db/queries.ts new file mode 100644 index 0000000..046f51e --- /dev/null +++ b/db/queries.ts @@ -0,0 +1,146 @@ +import { eq } from 'drizzle-orm'; +import { db } from 'officerdb/db'; +import { encryptSecret, decryptSecret } from 'officerdb/crypto'; +import { linkwardenConfig, linkwardenAccounts } from './schema'; + +// Linkwarden store access. Callers deal in PLAINTEXT — encryption to and from at-rest ciphertext happens +// here, so neither the proxy nor the provisioner touches crypto directly. The key purpose is +// `linkwarden`, minted on first use by the secret store (docs/secret-store.md). + +// ── Instance configuration (server-wide, one row) ── + +export type LinkwardenConfig = { url: string | null; enabled: boolean; updatedAt: Date | null }; + +const EMPTY_CONFIG: LinkwardenConfig = { url: null, enabled: true, updatedAt: null }; + +/** + * Where this install's Linkwarden is, or `url: null` when the owner has not pointed it anywhere yet. + * + * Never throws and never creates the row. A missing row and an unset URL are the same answer to the only + * question anyone asks here, and an install nobody has configured is an ordinary state rather than an + * error. The admin token is deliberately NOT returned — see `getAdminToken`. + */ +export async function getLinkwardenConfig(): Promise { + const [row] = await db.select().from(linkwardenConfig).where(eq(linkwardenConfig.id, 1)); + if (!row) return EMPTY_CONFIG; + return { url: row.url, enabled: row.enabled, updatedAt: row.updatedAt }; +} + +/** Trailing slash removed on write, so `${url}/api/v1/...` can never double the separator. */ +const normaliseUrl = (raw: string): string => raw.trim().replace(/\/+$/, ''); + +/** + * Point this install at a Linkwarden, or change which one. + * + * The URL is stored as given (normalised) and NOT probed. Reachability is a separate question with its + * own route, and a setter that refused an unreachable address would make configuring an instance that is + * temporarily down impossible. + */ +export async function setLinkwardenConfig(patch: { + url?: string | null; + enabled?: boolean; + adminToken?: string | null; +}): Promise { + const values = { + id: 1, + ...(patch.url !== undefined ? { url: patch.url === null ? null : normaliseUrl(patch.url) } : {}), + ...(patch.enabled !== undefined ? { enabled: patch.enabled } : {}), + ...(patch.adminToken !== undefined + ? { adminToken: patch.adminToken === null ? null : await encryptSecret('linkwarden', patch.adminToken) } + : {}), + updatedAt: new Date(), + }; + + await db + .insert(linkwardenConfig) + .values(values) + .onConflictDoUpdate({ target: linkwardenConfig.id, set: values }); + + return getLinkwardenConfig(); +} + +/** + * The admin token, decrypted. Read by provisioning and by nothing else. + * + * Separate from `getLinkwardenConfig` on purpose: that one is handed to the settings panel and to the + * sidecar, and neither has any business holding a credential that can create accounts. A single function + * that returned everything would make the careful thing the one you have to remember to do. + */ +export async function getAdminToken(): Promise { + const [row] = await db.select().from(linkwardenConfig).where(eq(linkwardenConfig.id, 1)); + if (!row?.adminToken) return null; + return decryptSecret('linkwarden', row.adminToken); +} + +// ── Account mapping (one row per Officer user) ── + +export type LinkwardenAccount = { + userId: number; + linkwardenUserId: number; + username: string; + /** Null when an account exists but no token has been minted for it yet. A real state, not an error. */ + accessToken: string | null; +}; + +/** Who this Officer user is on Linkwarden, or null when nobody has mapped them. */ +export async function getAccount(userId: number): Promise { + const [row] = await db.select().from(linkwardenAccounts).where(eq(linkwardenAccounts.userId, userId)); + if (!row) return null; + return { + userId: row.userId, + linkwardenUserId: row.linkwardenUserId, + username: row.username, + accessToken: row.accessToken ? await decryptSecret('linkwarden', row.accessToken) : null, + }; +} + +/** Every mapping, for the owner's account screen. Tokens are NOT included — only whether one exists. */ +export async function listAccounts(): Promise< + { userId: number; linkwardenUserId: number; username: string; hasToken: boolean }[] +> { + const rows = await db.select().from(linkwardenAccounts); + return rows.map((row) => ({ + userId: row.userId, + linkwardenUserId: row.linkwardenUserId, + username: row.username, + hasToken: !!row.accessToken, + })); +} + +/** Map an Officer user onto a Linkwarden account, or replace the mapping. Upsert on `userId`. */ +export async function setAccount(account: { + userId: number; + linkwardenUserId: number; + username: string; + accessToken?: string | null; +}): Promise { + const values = { + userId: account.userId, + linkwardenUserId: account.linkwardenUserId, + username: account.username, + ...(account.accessToken !== undefined + ? { + accessToken: + account.accessToken === null ? null : await encryptSecret('linkwarden', account.accessToken), + } + : {}), + updatedAt: new Date(), + }; + + await db.insert(linkwardenAccounts).values(values).onConflictDoUpdate({ target: linkwardenAccounts.userId, set: values }); +} + +/** + * Forget the mapping. Does NOT delete the Linkwarden account. + * + * Deliberate: Officer created it but does not own what is in it, and a person removed from an Officer + * install should not silently lose a library of saved reading. Deleting it there is a separate act, done + * in Linkwarden, by somebody who means it. + */ +export async function forgetAccount(userId: number): Promise { + const rows = await db + .delete(linkwardenAccounts) + .where(eq(linkwardenAccounts.userId, userId)) + .returning({ userId: linkwardenAccounts.userId }); + return rows.length > 0; +} diff --git a/db/schema.ts b/db/schema.ts index 8a47823..058fe9e 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -23,7 +23,20 @@ export const linkwardenConfig = pgTable( { /** Always 1. A single-row table wants to say so structurally, or a second row arrives eventually. */ id: integer('id').primaryKey().default(1), - /** Base URL, no trailing slash (normalised on write). Null before the owner has pointed it anywhere. */ + /** + * Base URL, no trailing slash (normalised on write). Null before the owner has pointed it anywhere. + * + * ── This is the address the SIDECAR dials, which is not the one clients are told ── + * + * They come apart whenever Officer and Linkwarden share a host, and on this install they do. Clients + * are told `http://alpha:3000` and that is right for them: a browser elsewhere resolves it over the + * tailnet to 100.64.0.1. But ON alpha the same name resolves to 127.0.1.1, and Linkwarden binds only + * 127.0.0.1 and 100.64.0.1 — so the sidecar, running there, dials the name it advertises and gets + * nothing. Measured: `http://alpha:3000` answers 000 from alpha and 200 from anywhere else. + * + * So this holds the loopback address and `NEXTAUTH_URL` holds the public one. Storing the advertised + * name here would work from every machine except the one it runs on. + */ url: text('url'), /** * The admin access token, used ONLY to provision accounts — `plugins/USER_PROVISIONING.md`. diff --git a/sidecar/index.ts b/sidecar/index.ts new file mode 100644 index 0000000..358ff00 --- /dev/null +++ b/sidecar/index.ts @@ -0,0 +1,123 @@ +import { createSidecarConnector } from '@@/sidecar/connect'; +import { API_URL } from '@@/officer-url.mjs'; +import { getLinkwardenBase, stripHopByHop, redactPath } from './upstream'; + +// The officer-linkwarden sidecar: a singleton process that registers with the API server and owns a +// transparent reverse-proxy to a self-hosted Linkwarden. The platform API is a thin forwarder to this; +// ALL knowledge of Linkwarden — where it is, whether it is switched on — lives here, which is what lets +// the plugin be a plugin rather than something the platform has to learn about. +// +// ── Pass-through, with one exception that is the point of the plugin ── +// +// Method, path, query, headers, status and both body streams go through verbatim. Nothing is buffered, +// parsed, rewritten or cached. The one exception is the Authorization header on the AUTHENTICATED door: +// the platform has already decided who is calling and swapped in that person's Linkwarden token, so a +// device holding an Officer session never holds a Linkwarden credential. This process does not do that +// swap and does not know the tokens — it forwards what it is handed. See `api/router.ts`. +// +// ── No WebSocket ── +// +// Vault's sidecar proxies one, because Vaultwarden pushes notifications over SignalR. Linkwarden has no +// equivalent: it is request/response over HTTP, and its own UI polls. A WebSocket path here would be +// scaffolding for a protocol that does not exist. + +/** Grab an ephemeral free port by briefly binding one and releasing it. */ +function getFreePort(): number { + const probe = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') }); + const port = probe.port; + probe.stop(true); + if (port == null) throw new Error('failed to acquire a free port'); + return port; +} + +const PORT = getFreePort(); + +Bun.serve({ + port: PORT, + hostname: '127.0.0.1', + idleTimeout: 120, + + async fetch(req) { + const url = new URL(req.url); + + /* + Ours, not part of the contract. Probes the upstream so "is the sidecar up" and "can it reach + Linkwarden" are two answerable questions rather than one ambiguous timeout. + */ + if (url.pathname === '/_health') { + const base = await getLinkwardenBase(); + if (!base) return Response.json({ ok: false, reason: 'unconfigured' }, { status: 503 }); + try { + const probe = await fetch(`${base}/api/v1/auth/providers`, { + signal: AbortSignal.timeout(5000), + }); + return Response.json({ ok: probe.ok, upstream: probe.status }); + } catch { + return Response.json({ ok: false, reason: 'unreachable' }, { status: 502 }); + } + } + + const base = await getLinkwardenBase(); + if (!base) return new Response('Linkwarden is not configured', { status: 503 }); + + const target = `${base}${url.pathname}${url.search}`; + const hasBody = req.method !== 'GET' && req.method !== 'HEAD'; + + const init: RequestInit & { duplex?: 'half' } = { + method: req.method, + headers: stripHopByHop(req.headers), + // `manual`, so a redirect is handed back to the caller exactly as Linkwarden wrote it rather than + // being followed here. NextAuth redirects are part of its protocol and a proxy that resolves them + // silently changes what the client sees. + redirect: 'manual', + ...(hasBody ? { body: req.body, duplex: 'half' as const } : {}), + }; + + let upstream: Response; + try { + upstream = await fetch(target, init); + } catch (err) { + console.error( + `[linkwarden] ${req.method} ${redactPath(url.pathname + url.search)} -> upstream unreachable:`, + err instanceof Error ? err.message : String(err), + ); + return new Response('Linkwarden unreachable', { status: 502 }); + } + + return new Response(upstream.body, { + status: upstream.status, + headers: stripHopByHop(upstream.headers), + }); + }, +}); + +console.log(`[linkwarden] proxy listening on 127.0.0.1:${PORT}`); + +/* + Tell the platform where to find us. + + The port is ephemeral and chosen fresh on every start, so it is not a value anybody configures — it is + reported on connect and the platform routes to whatever the last connect said. That is what makes + restarting this process invisible to the API server, and it is re-sent on every reconnect rather than + once at boot, because the platform may be the side that restarted. +*/ +const connection = createSidecarConnector({ + apiUrl: `${API_URL}/api/sidecar/register`, + name: 'linkwarden', + handles: ['linkwarden'], + + /* + Required by the connector, and deliberately empty. + + There is no command vocabulary here. Vault has one because it brokers a login; this sidecar only + forwards bytes, and everything that decides anything — which user, which token, whether they may — + happens on the platform side before a request arrives. A command channel would be a second way to + reach a process whose whole value is that it does one thing. + */ + onCommand() {}, + + onConnected() { + connection.send({ type: 'linkwarden:server', port: PORT }); + console.log(`[linkwarden] reported proxy port ${PORT} to API`); + }, +}); diff --git a/sidecar/upstream.ts b/sidecar/upstream.ts new file mode 100644 index 0000000..4133f9a --- /dev/null +++ b/sidecar/upstream.ts @@ -0,0 +1,89 @@ +import { getLinkwardenConfig } from '../db/queries'; + +// Where Linkwarden is, for the officer-linkwarden sidecar, plus header hygiene. +// +// ── The URL comes from the database, and can change while this is running ── +// +// `db/schema.ts` says why it is not an environment variable. The consequence here is the interesting +// part: a value that would have been constant for the process lifetime is now something the owner can +// change from a settings panel without restarting anything. +// +// ── Why a cache with a TTL rather than a read per request ── +// +// This sits in front of every proxied byte. A Postgres round trip per request would put the database on +// the hot path of a proxy whose whole design rule is to do as little as possible. Ten seconds is chosen +// against what the value actually is — a URL edited by hand, roughly never, and then wanted immediately. +// Imperceptible to somebody who just pressed Save, and free the rest of the time. +// +// The TTL is the WHOLE mechanism. There is no push: `sidecar-registry.ts` carries events from a sidecar +// to the platform, not commands the other way, so the config route has nothing to call. Building that +// channel for a cache hint would be a channel built for the wrong reason. +// +// A failed read keeps serving the last known-good value rather than dropping to null, because "cannot +// reach Postgres" and "the owner never configured this" look identical on screen and are not the same. + +const TTL_MS = 10_000; + +type Cached = { url: string | null; enabled: boolean; at: number }; +let cache: Cached | null = null; +let inflight: Promise | null = null; + +async function read(): Promise { + try { + const config = await getLinkwardenConfig(); + cache = { url: config.url, enabled: config.enabled, at: Date.now() }; + return cache; + } catch (err) { + console.error(`[linkwarden] could not read config: ${err instanceof Error ? err.message : String(err)}`); + if (cache) return { ...cache, at: Date.now() }; + return { url: null, enabled: true, at: Date.now() }; + } +} + +async function current(): Promise { + if (cache && Date.now() - cache.at < TTL_MS) return cache; + // One read at a time. Without this, every request under load misses the same expired entry and fires + // its own query — the stampede that makes a cache slower than no cache. + inflight ??= read().finally(() => { + inflight = null; + }); + return inflight; +} + +/** The base URL to forward to, or null when unconfigured or switched off. No trailing slash. */ +export async function getLinkwardenBase(): Promise { + const { url, enabled } = await current(); + return enabled ? url : null; +} + +const HOP_BY_HOP = new Set([ + 'connection', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', + 'host', +]); + +/** Copy headers, dropping only hop-by-hop and `proxy-*` ones. Authorization and cookies pass through. */ +export function stripHopByHop(src: Headers): Headers { + const out = new Headers(); + src.forEach((value, key) => { + const k = key.toLowerCase(); + if (HOP_BY_HOP.has(k) || k.startsWith('proxy-')) return; + out.set(key, value); + }); + return out; +} + +/* + Paths are logged; tokens are not. + + Linkwarden puts an access token in a query string in more than one place, and a NextAuth callback + carries a csrf token the same way. None of it belongs in a log that gets pasted into a chat. +*/ +const REDACT = /([?&](access_token|token|csrfToken|code)=)[^&]*/gi; +export const redactPath = (pathAndQuery: string): string => pathAndQuery.replace(REDACT, '$1');