Same model as Vault: one instance per install, a sidecar that is the only thing
knowing where it is, two doors - one authenticated for our own UI that injects
the caller's credential, one unauthenticated for a client that carries its own -
and an account per Officer user.
── Why this upstream ──
Linkwarden authenticates its extension through NextAuth: host-scoped cookies,
CSRF checked against the host, absolute URLs baked from NEXTAUTH_URL. Proxying
that means telling the upstream the address the CLIENT uses, which is not the
address the SIDECAR dials, and getting it wrong bounces the extension out of the
proxy mid-login. It cost a NEXTAUTH_URL change, a container recreate, a lost csrf
cookie, and it retired the upstream's own web login as the price of going through
Officer.
Karakeep's extension holds an API key and sends it as a header. Nothing is
host-pinned, so none of that exists. And provisioning needs nothing pasted:
users.create and apiKeys.exchange are both public procedures, so Officer creates
the account, chooses the password, and mints the key itself - where Linkwarden
could only take a key a person made by hand.
── Carried over deliberately ──
Two fixes from the Linkwarden build, both upstream-agnostic and both found by
running it rather than reading it. `content-encoding` stripped from proxied
responses, because fetch decompresses the body while the headers still claim
gzip - which hides until the first response big enough to compress. And
Set-Cookie copied with append rather than set, because it is the one header that
legitimately repeats. Karakeep should never need the second one; it is here
because a proxy correct only for the traffic its author tested is a trap for
whoever changes the upstream later.
Also carried: the unauthenticated door is hand-written rather than the shared
createSidecarProxy router, which does ctx.get("user").id and 500s where there is
no session.
Written against source, not yet run. The response shapes in the panels come from
packages/api/routes/ and are the first thing to check against a live instance.
No provisioning yet.
131 lines
5.5 KiB
TypeScript
131 lines
5.5 KiB
TypeScript
import { getKarakeepConfig } from '../db/queries';
|
|
|
|
// Where Karakeep is, for the officer-karakeep 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<Cached> | null = null;
|
|
|
|
async function read(): Promise<Cached> {
|
|
try {
|
|
const config = await getKarakeepConfig();
|
|
cache = { url: config.url, enabled: config.enabled, at: Date.now() };
|
|
return cache;
|
|
} catch (err) {
|
|
console.error(`[karakeep] 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<Cached> {
|
|
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 getKarakeepBase(): Promise<string | null> {
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Headers for a response we are handing back, which is NOT the same as headers for a request.
|
|
*
|
|
* ── `content-encoding` has to go, and finding out why cost a 502 ──
|
|
*
|
|
* `fetch` decompresses a gzipped response body before anyone sees it. The headers it returns still say
|
|
* `content-encoding: gzip`, so copying them onto the decoded bytes tells the client to gunzip plain
|
|
* JSON — `Decompression error: ZlibError`, surfacing as a 502 from the catch below.
|
|
*
|
|
* It hides well. A 401 body is a few bytes and is not compressed, so every unauthenticated probe passes
|
|
* and the first REAL request is the one that fails. Measured exactly that way on alpha: the door
|
|
* answered 401 correctly without a token and 502 with one.
|
|
*
|
|
* `content-length` goes with it for the same reason — it describes the compressed length, and the body
|
|
* being handed on is not that length.
|
|
*/
|
|
export function stripResponseHeaders(src: Headers): Headers {
|
|
const out = stripHopByHop(src);
|
|
out.delete('content-encoding');
|
|
out.delete('content-length');
|
|
|
|
/*
|
|
── `Set-Cookie` is the one header that legitimately repeats, and `.set()` keeps only the last ──
|
|
|
|
`stripHopByHop` copies with `out.set(key, value)`, which OVERWRITES. Fine for every other header and
|
|
wrong for this one: a response setting three cookies arrives as one.
|
|
|
|
Karakeep authenticates with an API key and should never need this — which is exactly why it is here.
|
|
The sister plugin built against Linkwarden lost a csrf cookie this way and produced a sign-in that
|
|
looped with nothing in any log to explain it. A proxy that is correct only for the traffic its author
|
|
happened to test is a trap for whoever changes the upstream later.
|
|
|
|
`getSetCookie()` returns the values unjoined, which matters because a cookie's `Expires` contains a
|
|
comma and naive comma-splitting corrupts it.
|
|
*/
|
|
out.delete('set-cookie');
|
|
for (const cookie of src.getSetCookie()) out.append('set-cookie', cookie);
|
|
|
|
return out;
|
|
}
|
|
|
|
/*
|
|
Paths are logged; tokens are not.
|
|
|
|
Karakeep 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<redacted>');
|