Files
pastilhas ed40b6798c a gzipped response was handed on still labelled gzipped
`fetch` decompresses a response body before anyone sees it, and the headers it
returns still say `content-encoding: gzip`. Copying those onto the decoded bytes
tells the client to gunzip plain JSON: `Decompression error: ZlibError`, which
surfaced as a 502 from the catch that wraps the forward.

It hides well, and that is the part worth writing down. A 401 body is a few bytes
and is not compressed, so every unauthenticated probe passes. The first request
carrying a real credential is the first one big enough to be gzipped, and the
first one to fail - which reads as "the credential broke it".

Measured on alpha in exactly that order: the extension door answered 401 without
a token and 502 with one.

`content-length` goes too. It describes the compressed length and the body being
handed on is not that length.

Response headers are now a different function from request headers, because they
are a different question and treating them as one is what caused this.
2026-09-05 21:05:06 +01:00

124 lines
5.1 KiB
TypeScript

import { createSidecarConnector } from '@@/sidecar/connect';
import { API_URL } from '@@/officer-url.mjs';
import { getLinkwardenBase, stripHopByHop, stripResponseHeaders, 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: stripResponseHeaders(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`);
},
});