From 3cf9c822b48ac40b6cd9f520794e1f88210cacf1 Mon Sep 17 00:00:00 2001 From: Trezy Date: Mon, 17 Aug 2026 14:32:51 -0500 Subject: [PATCH] feat: allow pds admin url customization Signed-off-by: Trezy --- .env.example | 7 +++++ server/src/index.ts | 22 ++++++++++++- server/src/pdsClient.ts | 25 ++++++++++++--- server/src/pdsUrls.ts | 18 +++++++++++ server/test/pdsClient.test.ts | 58 +++++++++++++++++++++++++++++++++++ server/test/pdsUrls.test.ts | 35 +++++++++++++++++++++ 6 files changed, 159 insertions(+), 6 deletions(-) create mode 100644 server/src/pdsUrls.ts create mode 100644 server/test/pdsClient.test.ts create mode 100644 server/test/pdsUrls.test.ts diff --git a/.env.example b/.env.example index c5f93a0..181f9d3 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,13 @@ PDS_ADMIN_PASSWORD=changeme # (an app password works). Leave empty for the reference PDS. PDS_ADMIN_IDENTIFIER= +# Optional: base URL the backend uses to REACH the PDS. Defaults to https://PDS_HOSTNAME. +# Set this to connect over a private network (e.g. a co-located host or Fly 6PN) so the +# PDS's admin endpoints need not be exposed publicly. Must be a full http(s) URL; the +# firehose ws/wss scheme is derived from it. PDS_HOSTNAME is still required (used for the +# passkey origin, relay crawl status, and profile links). +# PDS_ADMIN_URL=http://pds.internal:3000 + # Relay used for sync status / requestCrawl RELAY_HOSTNAME=bsky.network diff --git a/server/src/index.ts b/server/src/index.ts index 32f5ee7..fb825e0 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -26,6 +26,7 @@ const { PDS_HOSTNAME, PDS_ADMIN_PASSWORD, PDS_ADMIN_IDENTIFIER, + PDS_ADMIN_URL, RELAY_HOSTNAME, OPERATOR_PASSWORD_HASH, SESSION_SECRET, @@ -41,6 +42,20 @@ for (const [name, val] of Object.entries({ if (!val) throw new Error(`missing required env var: ${name}`); } +// Optional override for the base URL used to reach the PDS (defaults to +// https://PDS_HOSTNAME). Lets the backend connect over a private network so admin +// endpoints need not be exposed publicly. Must be a full http(s) URL. +if (PDS_ADMIN_URL) { + try { + const scheme = new URL(PDS_ADMIN_URL).protocol; + if (scheme !== "http:" && scheme !== "https:") throw new Error("scheme must be http or https"); + } catch (err) { + throw new Error( + `PDS_ADMIN_URL must be a valid http(s) URL, e.g. http://pds.internal:3000: ${String(err)}`, + ); + } +} + const isProd = process.env.NODE_ENV === "production"; // forged operator sessions are game over — refuse to boot on a weak secret @@ -77,7 +92,12 @@ await app.register(fastifySession, { }, }); -const pds = new PdsClient(PDS_HOSTNAME!, PDS_ADMIN_PASSWORD!, PDS_ADMIN_IDENTIFIER || undefined); +const pds = new PdsClient( + PDS_HOSTNAME!, + PDS_ADMIN_PASSWORD!, + PDS_ADMIN_IDENTIFIER || undefined, + PDS_ADMIN_URL || undefined, +); const relay = new RelayClient(RELAY_HOSTNAME!); // labelers.json: [{ "name"?, "did", "labels": [...] }] — labels empty/omitted means all labels flag. // Falls back to LABELER_DID / FLAG_LABELS env vars if the file doesn't exist. diff --git a/server/src/pdsClient.ts b/server/src/pdsClient.ts index b7353d4..2a3ff9c 100644 --- a/server/src/pdsClient.ts +++ b/server/src/pdsClient.ts @@ -1,6 +1,7 @@ import { randomBytes } from "node:crypto"; import WebSocket from "ws"; import { decodeMultiple } from "cbor-x"; +import { pdsBaseUrls } from "./pdsUrls.js"; export interface RepoEntry { did: string; @@ -39,6 +40,8 @@ export class PdsClient { private accessJwt: string | null = null; private refreshJwt: string | null = null; private sessionPromise: Promise | null = null; + private readonly httpBase: string; + private readonly wsBase: string; constructor( public readonly hostname: string, @@ -50,10 +53,22 @@ export class PdsClient { * then this account's password — an app password works and sidesteps 2FA. */ private adminIdentifier?: string, - ) {} + /** + * Base URL the backend uses to reach the PDS, e.g. "http://pds.internal:3000". + * Defaults to `https://${hostname}`. Set this to reach the PDS over a private + * network (e.g. Fly 6PN or another internal address) so its admin endpoints need + * not be exposed publicly. `hostname` is still the canonical public hostname and is + * used elsewhere (passkey origin, relay crawl status, profile links). + */ + adminUrl?: string, + ) { + const { http, ws } = pdsBaseUrls(hostname, adminUrl); + this.httpBase = http; + this.wsBase = ws; + } private async createSession(): Promise { - const res = await fetch(`https://${this.hostname}/xrpc/com.atproto.server.createSession`, { + const res = await fetch(`${this.httpBase}/xrpc/com.atproto.server.createSession`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ identifier: this.adminIdentifier, password: this.adminPassword }), @@ -92,7 +107,7 @@ export class PdsClient { private async refreshSession(): Promise { if (!this.refreshJwt) return false; try { - const res = await fetch(`https://${this.hostname}/xrpc/com.atproto.server.refreshSession`, { + const res = await fetch(`${this.httpBase}/xrpc/com.atproto.server.refreshSession`, { method: "POST", headers: { Authorization: `Bearer ${this.refreshJwt}` }, }); @@ -125,7 +140,7 @@ export class PdsClient { const authorization = accessJwt ? `Bearer ${accessJwt}` : `Basic ${Buffer.from(`admin:${this.adminPassword}`).toString("base64")}`; - const res = await fetch(`https://${this.hostname}/xrpc/${path}`, { + const res = await fetch(`${this.httpBase}/xrpc/${path}`, { ...opts, headers: { ...opts.headers, @@ -239,7 +254,7 @@ export class PdsClient { async approximateHeadSeq(fromSeq: number, windowMs = 3000): Promise { return new Promise((resolve) => { const ws = new WebSocket( - `wss://${this.hostname}/xrpc/com.atproto.sync.subscribeRepos?cursor=${fromSeq}`, + `${this.wsBase}/xrpc/com.atproto.sync.subscribeRepos?cursor=${fromSeq}`, ); let maxSeq: number | null = null; let opened = false; diff --git a/server/src/pdsUrls.ts b/server/src/pdsUrls.ts new file mode 100644 index 0000000..a846e5e --- /dev/null +++ b/server/src/pdsUrls.ts @@ -0,0 +1,18 @@ +/** + * Resolve the base URLs the backend uses to reach the PDS. + * + * By default the PDS is reached at `https://${hostname}` — its public identity. Pass + * `adminUrl` (a full http(s) URL, e.g. "http://pds.internal:3000") to dial a different + * endpoint, e.g. over a private network so the PDS's admin API need not be exposed + * publicly. The firehose ws/wss scheme is derived from the resolved http scheme. + * + * Only the origin (protocol + host + port) is used; any path in `adminUrl` is ignored, + * since xrpc paths are appended by the caller. + */ +export function pdsBaseUrls(hostname: string, adminUrl?: string): { http: string; ws: string } { + const url = new URL(adminUrl ?? `https://${hostname}`); + return { + http: `${url.protocol}//${url.host}`, + ws: `${url.protocol === "https:" ? "wss:" : "ws:"}//${url.host}`, + }; +} diff --git a/server/test/pdsClient.test.ts b/server/test/pdsClient.test.ts new file mode 100644 index 0000000..81d347b --- /dev/null +++ b/server/test/pdsClient.test.ts @@ -0,0 +1,58 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import { AddressInfo } from "node:net"; +import { WebSocketServer } from "ws"; +import { PdsClient } from "../src/pdsClient.js"; + +// The PDS_ADMIN_URL override lets the backend reach the PDS over a private network +// (plain http, custom port) instead of https://PDS_HOSTNAME. These tests prove the +// override actually changes the scheme/port of the requests the client makes. + +test("adminUrl override routes admin calls over plain http to a custom port", async () => { + let seenUrl: string | undefined; + let seenAuth: string | undefined; + const server = createServer((req, res) => { + seenUrl = req.url; + seenAuth = req.headers.authorization; + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ repos: [] })); + }); + await new Promise((r) => server.listen(0, "127.0.0.1", () => r())); + const { port } = server.address() as AddressInfo; + try { + const pds = new PdsClient("pds.example.com", "s3cret", undefined, `http://127.0.0.1:${port}`); + const out = await pds.listRepos(); + + assert.deepEqual(out.repos, []); + assert.ok(seenUrl?.startsWith("/xrpc/com.atproto.sync.listRepos"), `unexpected path: ${seenUrl}`); + // admin-password mode signs requests with HTTP Basic admin: + assert.equal(seenAuth, `Basic ${Buffer.from("admin:s3cret").toString("base64")}`); + } finally { + server.close(); + } +}); + +test("an http adminUrl derives a ws:// (not wss://) firehose connection", async () => { + const wss = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + await new Promise((r) => wss.once("listening", () => r())); + const { port } = wss.address() as AddressInfo; + let connected = false; + wss.on("connection", (ws) => { + connected = true; + ws.close(); + }); + try { + const pds = new PdsClient("pds.example.com", "s3cret", undefined, `http://127.0.0.1:${port}`); + await pds.approximateHeadSeq(0, 500); + assert.ok(connected, "client should open a ws:// connection when adminUrl is http"); + } finally { + wss.close(); + } +}); + +test("without a adminUrl the default base is https://hostname", async () => { + // No override: falls back to the public hostname over https. We only assert it + // constructs cleanly (the https default is the pre-existing, covered behavior). + assert.doesNotThrow(() => new PdsClient("pds.example.com", "s3cret")); +}); diff --git a/server/test/pdsUrls.test.ts b/server/test/pdsUrls.test.ts new file mode 100644 index 0000000..a5e7a0b --- /dev/null +++ b/server/test/pdsUrls.test.ts @@ -0,0 +1,35 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { pdsBaseUrls } from "../src/pdsUrls.js"; + +test("defaults to https/wss on the public hostname when no adminUrl is given", () => { + assert.deepEqual(pdsBaseUrls("pds.example.com"), { + http: "https://pds.example.com", + ws: "wss://pds.example.com", + }); +}); + +test("an http adminUrl yields http/ws and preserves a custom port", () => { + assert.deepEqual(pdsBaseUrls("pds.example.com", "http://pds.internal:3000"), { + http: "http://pds.internal:3000", + ws: "ws://pds.internal:3000", + }); +}); + +test("an https adminUrl yields https/wss", () => { + assert.deepEqual(pdsBaseUrls("pds.example.com", "https://admin.pds.example.com"), { + http: "https://admin.pds.example.com", + ws: "wss://admin.pds.example.com", + }); +}); + +test("only the origin is used — any path in adminUrl is dropped", () => { + assert.deepEqual(pdsBaseUrls("ignored", "http://pds.internal:3000/some/path"), { + http: "http://pds.internal:3000", + ws: "ws://pds.internal:3000", + }); +}); + +test("a malformed adminUrl throws (fail fast at construction)", () => { + assert.throws(() => pdsBaseUrls("pds.example.com", "not a url")); +}); -- 2.51.2