`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.
124 lines
5.1 KiB
TypeScript
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`);
|
|
},
|
|
});
|