@openmeet/snowglobe 0.0.0-stage → 0.3.0-rc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +28 -0
- package/LICENSE +21 -0
- package/README.md +143 -2
- package/bin/snowglobe.mjs +69 -0
- package/lib/apps.mjs +97 -0
- package/lib/args.mjs +29 -0
- package/lib/certs.mjs +211 -0
- package/lib/commands/account.mjs +85 -0
- package/lib/commands/app.mjs +86 -0
- package/lib/commands/certs.mjs +36 -0
- package/lib/commands/check.mjs +37 -0
- package/lib/commands/down.mjs +37 -0
- package/lib/commands/env.mjs +145 -0
- package/lib/commands/invite.mjs +48 -0
- package/lib/commands/lexicons.mjs +94 -0
- package/lib/commands/render.mjs +63 -0
- package/lib/commands/run.mjs +109 -0
- package/lib/commands/trust.mjs +45 -0
- package/lib/commands/up.mjs +257 -0
- package/lib/config.mjs +485 -0
- package/lib/env.mjs +364 -0
- package/lib/hosts.mjs +36 -0
- package/lib/render.mjs +570 -0
- package/lib/settings.mjs +146 -0
- package/lib/state.mjs +75 -0
- package/lib/trust.mjs +288 -0
- package/lib/up.mjs +54 -0
- package/lib/xrpc.mjs +49 -0
- package/package.json +55 -4
- package/snowglobe.default.yml +54 -0
- package/stack/docker-compose.relay.yml +105 -0
- package/stack/docker-compose.test.yml +41 -0
- package/stack/docker-compose.yml +178 -0
- package/stack/https/app-names.cjs +31 -0
- package/stack/https/dns/Dockerfile +10 -0
- package/stack/https/dns/dns.sh +40 -0
- package/stack/https/nginx.conf.template +104 -0
- package/stack/https/proxy/Dockerfile +8 -0
- package/stack/https/proxy/server.mjs +213 -0
- package/stack/init/init.sh +118 -0
- package/stack/page/fonts/OFL-jetbrains-mono.txt +93 -0
- package/stack/page/fonts/OFL-schibsted-grotesk.txt +93 -0
- package/stack/page/fonts/jetbrains-mono-latin.woff2 +0 -0
- package/stack/page/fonts/schibsted-grotesk-latin.woff2 +0 -0
- package/stack/page/index.html +409 -0
- package/stack/page/snowglobe.conf +59 -0
- package/stack/pdsls/Dockerfile +27 -0
- package/stack/pdsls/network.patch +294 -0
- package/stack/pdsls/server.mjs +192 -0
- package/stack/relay/Dockerfile +30 -0
- package/stack/relay/allow-private-hosts.patch +55 -0
- package/stack/slingshot/Dockerfile +36 -0
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
From c6cdd20285d5d8cc029b2c7178f4c2e1d8ac12b8 Mon Sep 17 00:00:00 2001
|
|
2
|
+
From: Tom Scanlan <tompscanlan@gmail.com>
|
|
3
|
+
Date: Mon, 19 Oct 2026 15:43:08 -0400
|
|
4
|
+
Subject: [PATCH] network: a host page can point PDSls at another atproto
|
|
5
|
+
network
|
|
6
|
+
|
|
7
|
+
globalThis.PDSLS_NETWORK, set before the app loads, gives the PLC directory,
|
|
8
|
+
DNS-over-HTTPS resolver, a fixed lexicon authority, the Bluesky API, Constellation,
|
|
9
|
+
the stream instances, and whether to sign in with client metadata at the page's
|
|
10
|
+
own origin. Unset, every address is the public network's, as before. null turns a
|
|
11
|
+
feature off, so a private network falls back to no public service.
|
|
12
|
+
---
|
|
13
|
+
src/auth/oauth-config.ts | 8 ++++--
|
|
14
|
+
src/auth/session-manager.ts | 4 ++-
|
|
15
|
+
src/components/search.tsx | 8 +++---
|
|
16
|
+
src/lib/api.ts | 49 +++++++++++++++++++++++++------------
|
|
17
|
+
src/lib/network.ts | 37 ++++++++++++++++++++++++++++
|
|
18
|
+
src/views/settings.tsx | 3 ++-
|
|
19
|
+
src/views/stream/config.ts | 7 +++---
|
|
20
|
+
7 files changed, 91 insertions(+), 25 deletions(-)
|
|
21
|
+
create mode 100644 src/lib/network.ts
|
|
22
|
+
|
|
23
|
+
diff --git a/src/auth/oauth-config.ts b/src/auth/oauth-config.ts
|
|
24
|
+
index 95afb85..30188cd 100644
|
|
25
|
+
--- a/src/auth/oauth-config.ts
|
|
26
|
+
+++ b/src/auth/oauth-config.ts
|
|
27
|
+
@@ -2,6 +2,7 @@ import { LocalActorResolver } from "@atcute/identity-resolver";
|
|
28
|
+
import { configureOAuth } from "@atcute/oauth-browser-client";
|
|
29
|
+
|
|
30
|
+
import { didDocumentResolver, handleResolver } from "../lib/api";
|
|
31
|
+
+import { network } from "../lib/network";
|
|
32
|
+
|
|
33
|
+
const reactiveDidDocumentResolver = {
|
|
34
|
+
resolve: async (did: string) => didDocumentResolver().resolve(did as any),
|
|
35
|
+
@@ -9,8 +10,11 @@ const reactiveDidDocumentResolver = {
|
|
36
|
+
|
|
37
|
+
configureOAuth({
|
|
38
|
+
metadata: {
|
|
39
|
+
- client_id: import.meta.env.VITE_OAUTH_CLIENT_ID,
|
|
40
|
+
- redirect_uri: import.meta.env.VITE_OAUTH_REDIRECT_URL,
|
|
41
|
+
+ // A host that serves its own client metadata signs in as itself, wherever it is served.
|
|
42
|
+
+ client_id: network.oauthAtOrigin
|
|
43
|
+
+ ? `${location.origin}/oauth-client-metadata.json`
|
|
44
|
+
+ : import.meta.env.VITE_OAUTH_CLIENT_ID,
|
|
45
|
+
+ redirect_uri: network.oauthAtOrigin ? `${location.origin}/` : import.meta.env.VITE_OAUTH_REDIRECT_URL,
|
|
46
|
+
},
|
|
47
|
+
identityResolver: new LocalActorResolver({
|
|
48
|
+
handleResolver: handleResolver,
|
|
49
|
+
diff --git a/src/auth/session-manager.ts b/src/auth/session-manager.ts
|
|
50
|
+
index 4e65228..3ea2115 100644
|
|
51
|
+
--- a/src/auth/session-manager.ts
|
|
52
|
+
+++ b/src/auth/session-manager.ts
|
|
53
|
+
@@ -8,6 +8,7 @@ import {
|
|
54
|
+
} from "@atcute/oauth-browser-client";
|
|
55
|
+
|
|
56
|
+
import { resolveDidDoc } from "../lib/api";
|
|
57
|
+
+import { network } from "../lib/network";
|
|
58
|
+
import { oauthScopeStringToIds, scopeIdsToString } from "./scope-utils";
|
|
59
|
+
import { Sessions, setAgent, setSessions } from "./state";
|
|
60
|
+
|
|
61
|
+
@@ -21,8 +22,9 @@ export const loadSessionsFromStorage = (): Sessions | null => {
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export const getAvatar = async (did: Did): Promise<string | undefined> => {
|
|
65
|
+
+ if (!network.appviewUrl) return undefined;
|
|
66
|
+
const rpc = new Client({
|
|
67
|
+
- handler: simpleFetchHandler({ service: "https://public.api.bsky.app" }),
|
|
68
|
+
+ handler: simpleFetchHandler({ service: network.appviewUrl }),
|
|
69
|
+
});
|
|
70
|
+
const res = await rpc.get("app.bsky.actor.getProfile", { params: { actor: did } });
|
|
71
|
+
if (res.ok) {
|
|
72
|
+
diff --git a/src/components/search.tsx b/src/components/search.tsx
|
|
73
|
+
index 16039aa..b049d21 100644
|
|
74
|
+
--- a/src/components/search.tsx
|
|
75
|
+
+++ b/src/components/search.tsx
|
|
76
|
+
@@ -16,6 +16,7 @@ import { agent, avatars, sessions } from "../auth/state.js";
|
|
77
|
+
import { resolveLexiconAuthority, resolveLexiconAuthorityDirect } from "../lib/api";
|
|
78
|
+
import { appHandleLink, appList, AppUrl } from "../lib/app-urls";
|
|
79
|
+
import { createDebouncedValue } from "../lib/debounced";
|
|
80
|
+
+import { network } from "../lib/network";
|
|
81
|
+
import { carFileAccept, createFileChangeHandler } from "../views/car/file-handlers.js";
|
|
82
|
+
import { queueCarFile } from "../views/car/state.js";
|
|
83
|
+
|
|
84
|
+
@@ -92,9 +93,9 @@ const recentIcon = (type: RecentSearch["type"]) => {
|
|
85
|
+
|
|
86
|
+
export const Search = () => {
|
|
87
|
+
const navigate = useNavigate();
|
|
88
|
+
- const rpc = new Client({
|
|
89
|
+
- handler: simpleFetchHandler({ service: "https://public.api.bsky.app" }),
|
|
90
|
+
- });
|
|
91
|
+
+ const rpc = network.appviewUrl
|
|
92
|
+
+ ? new Client({ handler: simpleFetchHandler({ service: network.appviewUrl }) })
|
|
93
|
+
+ : null;
|
|
94
|
+
|
|
95
|
+
let searchRoot!: HTMLDivElement;
|
|
96
|
+
let searchPanel!: HTMLDivElement;
|
|
97
|
+
@@ -116,6 +117,7 @@ export const Search = () => {
|
|
98
|
+
const actorQuery = prefix === "@" ? query : value;
|
|
99
|
+
if (!actorQuery.length || actorQuery.includes("/") || /^https?:/i.test(actorQuery)) return [];
|
|
100
|
+
|
|
101
|
+
+ if (!rpc) return [];
|
|
102
|
+
const response = await rpc.get("app.bsky.actor.searchActorsTypeahead", {
|
|
103
|
+
params: { q: actorQuery, limit: 5 },
|
|
104
|
+
});
|
|
105
|
+
diff --git a/src/lib/api.ts b/src/lib/api.ts
|
|
106
|
+
index a706ebc..8ca2c95 100644
|
|
107
|
+
--- a/src/lib/api.ts
|
|
108
|
+
+++ b/src/lib/api.ts
|
|
109
|
+
@@ -23,6 +23,7 @@ import { createMemo } from "solid-js";
|
|
110
|
+
import { createStore } from "solid-js/store";
|
|
111
|
+
|
|
112
|
+
import { plcDirectory } from "../views/settings";
|
|
113
|
+
+import { network } from "./network";
|
|
114
|
+
|
|
115
|
+
const proxyFetch = (rewrite: (url: URL) => string): typeof fetch => {
|
|
116
|
+
return async (input, init) => {
|
|
117
|
+
@@ -61,17 +62,26 @@ export const didDocumentResolver = createMemo(
|
|
118
|
+
}),
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
-export const handleResolver = new CompositeHandleResolver({
|
|
122
|
+
- strategy: "dns-first",
|
|
123
|
+
- methods: {
|
|
124
|
+
- dns: new DohJsonHandleResolver({ dohUrl: "https://dns.google/resolve?", fetch: dnsProxyFetch }),
|
|
125
|
+
- http: new WellKnownHandleResolver({ fetch: handleHttpProxyFetch }),
|
|
126
|
+
- },
|
|
127
|
+
-});
|
|
128
|
+
-
|
|
129
|
+
-const authorityResolver = new DohJsonLexiconAuthorityResolver({
|
|
130
|
+
- dohUrl: "https://dns.google/resolve?",
|
|
131
|
+
-});
|
|
132
|
+
+// With no DNS on the network, a handle is resolved over HTTP only.
|
|
133
|
+
+export const handleResolver = network.dohUrl
|
|
134
|
+
+ ? new CompositeHandleResolver({
|
|
135
|
+
+ strategy: "dns-first",
|
|
136
|
+
+ methods: {
|
|
137
|
+
+ dns: new DohJsonHandleResolver({ dohUrl: network.dohUrl, fetch: dnsProxyFetch }),
|
|
138
|
+
+ http: new WellKnownHandleResolver({ fetch: handleHttpProxyFetch }),
|
|
139
|
+
+ },
|
|
140
|
+
+ })
|
|
141
|
+
+ : new WellKnownHandleResolver({ fetch: handleHttpProxyFetch });
|
|
142
|
+
+
|
|
143
|
+
+const authorityResolver = network.lexiconAuthority
|
|
144
|
+
+ ? { resolve: async (_nsid: Nsid) => network.lexiconAuthority as AtprotoDid }
|
|
145
|
+
+ : network.dohUrl
|
|
146
|
+
+ ? new DohJsonLexiconAuthorityResolver({ dohUrl: network.dohUrl })
|
|
147
|
+
+ : {
|
|
148
|
+
+ resolve: async (_nsid: Nsid): Promise<AtprotoDid> => {
|
|
149
|
+
+ throw new Error("No lexicon authority lookups on this network");
|
|
150
|
+
+ },
|
|
151
|
+
+ };
|
|
152
|
+
|
|
153
|
+
const schemaResolver = createMemo(
|
|
154
|
+
() =>
|
|
155
|
+
@@ -153,7 +163,9 @@ const resolveLexiconAuthority = async (nsid: Nsid) => {
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const resolveLexiconAuthorityDirect = async (authority: string) => {
|
|
159
|
+
- const dohUrl = "https://dns.google/resolve?";
|
|
160
|
+
+ if (network.lexiconAuthority) return network.lexiconAuthority;
|
|
161
|
+
+ const dohUrl = network.dohUrl;
|
|
162
|
+
+ if (!dohUrl) throw new Error("No lexicon authority lookups on this network");
|
|
163
|
+
const reversedAuthority = authority.split(".").reverse().join(".");
|
|
164
|
+
const domain = `_lexicon.${reversedAuthority}`;
|
|
165
|
+
const url = new URL(dohUrl);
|
|
166
|
+
@@ -263,7 +275,8 @@ const getConstellation = async (
|
|
167
|
+
cursor?: string,
|
|
168
|
+
limit?: number,
|
|
169
|
+
) => {
|
|
170
|
+
- const url = new URL("https://constellation.microcosm.blue");
|
|
171
|
+
+ if (!network.constellationUrl) throw new Error("Backlinks are not available on this network");
|
|
172
|
+
+ const url = new URL(network.constellationUrl);
|
|
173
|
+
url.pathname = endpoint;
|
|
174
|
+
url.searchParams.set("target", target);
|
|
175
|
+
if (collection) {
|
|
176
|
+
@@ -299,11 +312,17 @@ export interface HandleResolveResult {
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export const resolveHandleDetailed = async (handle: Handle) => {
|
|
180
|
+
- const dnsResolver = new DohJsonHandleResolver({ dohUrl: "https://dns.google/resolve?" });
|
|
181
|
+
+ const dnsResolver = network.dohUrl
|
|
182
|
+
+ ? new DohJsonHandleResolver({ dohUrl: network.dohUrl })
|
|
183
|
+
+ : {
|
|
184
|
+
+ resolve: async (_handle: Handle): Promise<AtprotoDid> => {
|
|
185
|
+
+ throw new Error("No DNS lookups on this network");
|
|
186
|
+
+ },
|
|
187
|
+
+ };
|
|
188
|
+
const httpResolver = new WellKnownHandleResolver({ fetch: handleHttpProxyFetch });
|
|
189
|
+
|
|
190
|
+
const tryResolve = async (
|
|
191
|
+
- resolver: DohJsonHandleResolver | WellKnownHandleResolver,
|
|
192
|
+
+ resolver: { resolve: (handle: Handle) => Promise<string> },
|
|
193
|
+
timeoutMs: number = 5000,
|
|
194
|
+
): Promise<HandleResolveResult> => {
|
|
195
|
+
try {
|
|
196
|
+
diff --git a/src/lib/network.ts b/src/lib/network.ts
|
|
197
|
+
new file mode 100644
|
|
198
|
+
index 0000000..cc75ea4
|
|
199
|
+
--- /dev/null
|
|
200
|
+
+++ b/src/lib/network.ts
|
|
201
|
+
@@ -0,0 +1,37 @@
|
|
202
|
+
+// The atproto network PDSls looks at. A host page can point it at a private network (a snowglobe, a
|
|
203
|
+
+// sandbox) by setting globalThis.PDSLS_NETWORK before the app loads; unset, every address is the
|
|
204
|
+
+// public network's, as before. A setting given as null turns its feature off, for a network that
|
|
205
|
+
+// has no such service, so nothing falls back to a public one.
|
|
206
|
+
+type NetworkSettings = {
|
|
207
|
+
+ /** Default PLC directory (a visitor can still change it in settings). */
|
|
208
|
+
+ plcDirectory?: string;
|
|
209
|
+
+ /** DNS-over-HTTPS resolver for handles and lexicon authorities; null: no DNS lookups. */
|
|
210
|
+
+ dohUrl?: string | null;
|
|
211
|
+
+ /** A DID that answers for every NSID, for a network whose lexicons have no DNS. */
|
|
212
|
+
+ lexiconAuthority?: string;
|
|
213
|
+
+ /** Bluesky API for actor search and avatars; null: off. */
|
|
214
|
+
+ appviewUrl?: string | null;
|
|
215
|
+
+ /** Constellation, for backlinks; null: off. */
|
|
216
|
+
+ constellationUrl?: string | null;
|
|
217
|
+
+ /** Default stream instances; null: that stream is off. */
|
|
218
|
+
+ jetstreamUrl?: string | null;
|
|
219
|
+
+ firehoseUrl?: string | null;
|
|
220
|
+
+ spacedustUrl?: string | null;
|
|
221
|
+
+ /** Sign in with a client whose metadata is at this page's own origin. */
|
|
222
|
+
+ oauthAtOrigin?: boolean;
|
|
223
|
+
+};
|
|
224
|
+
+
|
|
225
|
+
+const given: NetworkSettings = (globalThis as { PDSLS_NETWORK?: NetworkSettings }).PDSLS_NETWORK ?? {};
|
|
226
|
+
+const pick = <T>(value: T | undefined, fallback: T): T => (value === undefined ? fallback : value);
|
|
227
|
+
+
|
|
228
|
+
+export const network = {
|
|
229
|
+
+ plcDirectory: given.plcDirectory ?? "https://plc.directory",
|
|
230
|
+
+ dohUrl: pick(given.dohUrl, "https://dns.google/resolve?"),
|
|
231
|
+
+ lexiconAuthority: given.lexiconAuthority,
|
|
232
|
+
+ appviewUrl: pick(given.appviewUrl, "https://public.api.bsky.app"),
|
|
233
|
+
+ constellationUrl: pick(given.constellationUrl, "https://constellation.microcosm.blue"),
|
|
234
|
+
+ jetstreamUrl: pick(given.jetstreamUrl, "wss://jetstream1.us-east.bsky.network/subscribe"),
|
|
235
|
+
+ firehoseUrl: pick(given.firehoseUrl, "wss://bsky.network"),
|
|
236
|
+
+ spacedustUrl: pick(given.spacedustUrl, "wss://spacedust.microcosm.blue/subscribe"),
|
|
237
|
+
+ oauthAtOrigin: given.oauthAtOrigin === true,
|
|
238
|
+
+};
|
|
239
|
+
diff --git a/src/views/settings.tsx b/src/views/settings.tsx
|
|
240
|
+
index bed49ff..787a60e 100644
|
|
241
|
+
--- a/src/views/settings.tsx
|
|
242
|
+
+++ b/src/views/settings.tsx
|
|
243
|
+
@@ -2,10 +2,11 @@ import { createSignal } from "solid-js";
|
|
244
|
+
|
|
245
|
+
import { TextInput } from "../components/text-input.jsx";
|
|
246
|
+
import { ThemeSelection } from "../components/theme.jsx";
|
|
247
|
+
+import { network } from "../lib/network";
|
|
248
|
+
|
|
249
|
+
export const [hideMedia, setHideMedia] = createSignal(localStorage.hideMedia === "true");
|
|
250
|
+
const [plcDirectory, setPlcDirectory] = createSignal(
|
|
251
|
+
- localStorage.plcDirectory || "https://plc.directory",
|
|
252
|
+
+ localStorage.plcDirectory || network.plcDirectory,
|
|
253
|
+
);
|
|
254
|
+
export { plcDirectory };
|
|
255
|
+
|
|
256
|
+
diff --git a/src/views/stream/config.ts b/src/views/stream/config.ts
|
|
257
|
+
index a56c540..b68bd81 100644
|
|
258
|
+
--- a/src/views/stream/config.ts
|
|
259
|
+
+++ b/src/views/stream/config.ts
|
|
260
|
+
@@ -1,3 +1,4 @@
|
|
261
|
+
+import { network } from "../../lib/network";
|
|
262
|
+
import { localDateFromTimestamp } from "../../utils/format";
|
|
263
|
+
|
|
264
|
+
export type StreamType = "jetstream" | "firehose" | "spacedust";
|
|
265
|
+
@@ -37,7 +38,7 @@ export const STREAM_CONFIGS: Record<StreamType, StreamConfig> = {
|
|
266
|
+
label: "Jetstream",
|
|
267
|
+
description: "A simplified event stream with support for collection and DID filtering.",
|
|
268
|
+
icon: "lucide--radio-tower",
|
|
269
|
+
- defaultInstance: "wss://jetstream1.us-east.bsky.network/subscribe",
|
|
270
|
+
+ defaultInstance: network.jetstreamUrl ?? "",
|
|
271
|
+
useFirehoseLib: false,
|
|
272
|
+
collectionsLabel: "Top Collections",
|
|
273
|
+
fields: [
|
|
274
|
+
@@ -101,7 +102,7 @@ export const STREAM_CONFIGS: Record<StreamType, StreamConfig> = {
|
|
275
|
+
label: "Firehose",
|
|
276
|
+
description: "The raw event stream from a relay or PDS.",
|
|
277
|
+
icon: "lucide--rss",
|
|
278
|
+
- defaultInstance: "wss://bsky.network",
|
|
279
|
+
+ defaultInstance: network.firehoseUrl ?? "",
|
|
280
|
+
useFirehoseLib: true,
|
|
281
|
+
collectionsLabel: "Top Collections",
|
|
282
|
+
fields: [
|
|
283
|
+
@@ -136,7 +137,7 @@ export const STREAM_CONFIGS: Record<StreamType, StreamConfig> = {
|
|
284
|
+
label: "Spacedust",
|
|
285
|
+
description: "A stream of links showing interactions across the network.",
|
|
286
|
+
icon: "lucide--sparkles",
|
|
287
|
+
- defaultInstance: "wss://spacedust.microcosm.blue/subscribe",
|
|
288
|
+
+ defaultInstance: network.spacedustUrl ?? "",
|
|
289
|
+
useFirehoseLib: false,
|
|
290
|
+
collectionsLabel: "Top Sources",
|
|
291
|
+
fields: [
|
|
292
|
+
--
|
|
293
|
+
2.56.0
|
|
294
|
+
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// PDSls (pds.ls) for this network: the record browser apps link to, built from a pinned upstream
|
|
2
|
+
// commit with network.patch, which lets a host page give it the network's addresses. This serves
|
|
3
|
+
// the built app with those addresses injected, its own OAuth client metadata, and the three
|
|
4
|
+
// lookups the app falls back to when a browser fetch fails, answered from inside the network only.
|
|
5
|
+
// pds.ls's own Worker also renders link previews and favicons from the public network; this does
|
|
6
|
+
// not.
|
|
7
|
+
//
|
|
8
|
+
// GET /resolve-handle-http?handle=<h> https://<h>/.well-known/atproto-did, <h> under the domain
|
|
9
|
+
// GET /resolve-did-web?host=<h> https://<h>/.well-known/did.json, <h> under the domain
|
|
10
|
+
// GET /oauth-client-metadata.json a public client at PDSLS_PUBLIC_URL
|
|
11
|
+
// GET /_health 200
|
|
12
|
+
// anything else a file of the app, else the app itself (it routes in the page)
|
|
13
|
+
//
|
|
14
|
+
// Settings:
|
|
15
|
+
// PDSLS_PUBLIC_URL where browsers reach this, e.g. https://pdsls.local.snowglobe.at
|
|
16
|
+
// PDSLS_DOMAIN the network's domain; lookups outside it are refused
|
|
17
|
+
// PDSLS_PLC_URL the network's PLC directory
|
|
18
|
+
// PDSLS_JETSTREAM_URL the stream page's default Jetstream (wss://.../subscribe)
|
|
19
|
+
// PDSLS_FIREHOSE_URL the stream page's default relay or PDS (wss://...)
|
|
20
|
+
// PDSLS_LEXICON_AUTHORITY the DID that holds every lexicon on the network (optional)
|
|
21
|
+
// PDSLS_UPSTREAM host:port to send every lookup to, ignoring DNS (the snowglobe's nginx);
|
|
22
|
+
// unset, names resolve as usual (a globe's public names)
|
|
23
|
+
// PDSLS_DIST the built app (default ./dist)
|
|
24
|
+
// PDSLS_BIND host:port to listen on (default 0.0.0.0:8080)
|
|
25
|
+
// A lookup verifies TLS against the system's CAs plus NODE_EXTRA_CA_CERTS (the snowglobe's CA).
|
|
26
|
+
import dns from 'node:dns'
|
|
27
|
+
import { readFileSync, statSync } from 'node:fs'
|
|
28
|
+
import http from 'node:http'
|
|
29
|
+
import https from 'node:https'
|
|
30
|
+
import { extname, join, resolve, sep } from 'node:path'
|
|
31
|
+
|
|
32
|
+
const env = process.env
|
|
33
|
+
const PUBLIC_URL = (env.PDSLS_PUBLIC_URL ?? '').replace(/\/+$/, '')
|
|
34
|
+
const DOMAIN = (env.PDSLS_DOMAIN ?? '').toLowerCase()
|
|
35
|
+
const DIST = env.PDSLS_DIST ?? new URL('./dist', import.meta.url).pathname
|
|
36
|
+
const [BIND_HOST, BIND_PORT] = (env.PDSLS_BIND ?? '0.0.0.0:8080').split(':')
|
|
37
|
+
const [UP_HOST, UP_PORT] = (env.PDSLS_UPSTREAM ?? '').split(':')
|
|
38
|
+
const LOOKUP_MS = 5_000
|
|
39
|
+
const MAX_BYTES = 64 * 1024
|
|
40
|
+
|
|
41
|
+
/** Whether `host` is the network's domain or a name under it. */
|
|
42
|
+
export const inNetwork = (host, domain = DOMAIN) => {
|
|
43
|
+
const h = String(host).toLowerCase()
|
|
44
|
+
return Boolean(domain) && /^[a-z0-9.-]+$/.test(h) && (h === domain || h.endsWith(`.${domain}`))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The settings the app reads as globalThis.PDSLS_NETWORK. Services the network lacks are null. */
|
|
48
|
+
export function networkSettings(e = env) {
|
|
49
|
+
return {
|
|
50
|
+
plcDirectory: e.PDSLS_PLC_URL,
|
|
51
|
+
dohUrl: null,
|
|
52
|
+
lexiconAuthority: e.PDSLS_LEXICON_AUTHORITY || undefined,
|
|
53
|
+
appviewUrl: null,
|
|
54
|
+
constellationUrl: null,
|
|
55
|
+
jetstreamUrl: e.PDSLS_JETSTREAM_URL || null,
|
|
56
|
+
firehoseUrl: e.PDSLS_FIREHOSE_URL || null,
|
|
57
|
+
spacedustUrl: null,
|
|
58
|
+
oauthAtOrigin: true,
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The app's index.html with the network's settings set before any of its scripts run, and the
|
|
64
|
+
* public font host dropped (the text falls back to the browser's monospace font).
|
|
65
|
+
*/
|
|
66
|
+
export function injectSettings(html, settings) {
|
|
67
|
+
// JSON in a script element: escape what could end it.
|
|
68
|
+
const json = JSON.stringify(settings).replace(/</g, '\\u003c')
|
|
69
|
+
const tag = `<script>globalThis.PDSLS_NETWORK = ${json};</script>`
|
|
70
|
+
return html
|
|
71
|
+
.replace(/\s*<link[^>]+fonts\.bunny\.net[^>]*>/g, '')
|
|
72
|
+
.replace(/<head>/, `<head>\n ${tag}`)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** OAuth client metadata for a public browser client at `base`, with the scope pds.ls asks for. */
|
|
76
|
+
export function clientMetadata(base) {
|
|
77
|
+
return {
|
|
78
|
+
client_id: `${base}/oauth-client-metadata.json`,
|
|
79
|
+
client_name: 'PDSls',
|
|
80
|
+
client_uri: base,
|
|
81
|
+
logo_uri: `${base}/favicon.ico`,
|
|
82
|
+
redirect_uris: [`${base}/`],
|
|
83
|
+
scope:
|
|
84
|
+
'atproto repo:*?action=create repo:*?action=update repo:*?action=delete blob:*/* space:*?authority=*&action=read space:*?authority=*&collection=*&action=create&action=update&action=delete space:*?manage=create&manage=update&manage=delete',
|
|
85
|
+
grant_types: ['authorization_code', 'refresh_token'],
|
|
86
|
+
response_types: ['code'],
|
|
87
|
+
token_endpoint_auth_method: 'none',
|
|
88
|
+
application_type: 'web',
|
|
89
|
+
dpop_bound_access_tokens: true,
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** GET https://<host><path>; with PDSLS_UPSTREAM, connected there whatever the name resolves to. */
|
|
94
|
+
function lookup(host, path, accept) {
|
|
95
|
+
return new Promise((resolve, reject) => {
|
|
96
|
+
const req = https.get(
|
|
97
|
+
{
|
|
98
|
+
host,
|
|
99
|
+
servername: host,
|
|
100
|
+
path,
|
|
101
|
+
headers: { accept, 'user-agent': 'pdsls-network/1' },
|
|
102
|
+
timeout: LOOKUP_MS,
|
|
103
|
+
...(UP_HOST ? { lookup: (_name, opts, cb) => dns.lookup(UP_HOST, opts, cb), port: Number(UP_PORT) } : {}),
|
|
104
|
+
},
|
|
105
|
+
(res) => {
|
|
106
|
+
let size = 0
|
|
107
|
+
const chunks = []
|
|
108
|
+
res.on('data', (c) => {
|
|
109
|
+
size += c.length
|
|
110
|
+
if (size > MAX_BYTES) req.destroy(new Error('too large'))
|
|
111
|
+
else chunks.push(c)
|
|
112
|
+
})
|
|
113
|
+
res.on('end', () => resolve({ status: res.statusCode, type: res.headers['content-type'], body: Buffer.concat(chunks) }))
|
|
114
|
+
},
|
|
115
|
+
)
|
|
116
|
+
req.on('timeout', () => req.destroy(new Error('timed out')))
|
|
117
|
+
req.on('error', reject)
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const TYPES = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml', '.webp': 'image/webp', '.png': 'image/png', '.ico': 'image/x-icon', '.woff2': 'font/woff2', '.wasm': 'application/wasm', '.xml': 'application/xml', '.cur': 'image/x-icon' }
|
|
122
|
+
|
|
123
|
+
/** The file of the app at URL path `path` (still percent-encoded), or null. */
|
|
124
|
+
function file(path) {
|
|
125
|
+
try {
|
|
126
|
+
// Inside the folder, not merely starting with its name: /..%2fdist-backup/x is /app/dist-backup/x.
|
|
127
|
+
const root = resolve(DIST)
|
|
128
|
+
const full = join(root, decodeURIComponent(path))
|
|
129
|
+
if (!full.startsWith(root + sep) || !statSync(full).isFile()) return null
|
|
130
|
+
return full
|
|
131
|
+
} catch {
|
|
132
|
+
return null
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let indexHtml = null
|
|
137
|
+
const index = () => (indexHtml ??= injectSettings(readFileSync(join(DIST, 'index.html'), 'utf8'), networkSettings()))
|
|
138
|
+
|
|
139
|
+
async function serve(req, res) {
|
|
140
|
+
const url = new URL(req.url ?? '/', 'http://x')
|
|
141
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') return res.writeHead(405).end()
|
|
142
|
+
const send = (status, type, body, extra = {}) => {
|
|
143
|
+
res.writeHead(status, { 'content-type': type, 'x-content-type-options': 'nosniff', ...extra })
|
|
144
|
+
res.end(req.method === 'HEAD' ? undefined : body)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (url.pathname === '/_health') return send(200, 'text/plain', 'ok\n')
|
|
148
|
+
if (url.pathname === '/oauth-client-metadata.json') {
|
|
149
|
+
return send(200, 'application/json', JSON.stringify(clientMetadata(PUBLIC_URL), null, 2) + '\n', { 'cache-control': 'no-cache' })
|
|
150
|
+
}
|
|
151
|
+
const lookups = {
|
|
152
|
+
'/resolve-handle-http': ['handle', '/.well-known/atproto-did', 'text/plain'],
|
|
153
|
+
'/resolve-did-web': ['host', '/.well-known/did.json', 'application/did+ld+json,application/json'],
|
|
154
|
+
}
|
|
155
|
+
if (url.pathname in lookups) {
|
|
156
|
+
const [param, path, accept] = lookups[url.pathname]
|
|
157
|
+
const host = url.searchParams.get(param) ?? ''
|
|
158
|
+
if (!inNetwork(host)) return send(400, 'text/plain', `${param} is not on this network\n`)
|
|
159
|
+
try {
|
|
160
|
+
const r = await lookup(host, path, accept)
|
|
161
|
+
return send(r.status, r.type ?? 'application/octet-stream', r.body)
|
|
162
|
+
} catch (err) {
|
|
163
|
+
return send(502, 'text/plain', `lookup failed: ${err.message}\n`)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (url.pathname === '/resolve-handle-dns' || url.pathname === '/og-image' || url.pathname === '/favicon') {
|
|
167
|
+
return send(404, 'text/plain', 'not on this network\n')
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const path = file(url.pathname)
|
|
171
|
+
if (path && !path.endsWith('index.html')) {
|
|
172
|
+
const immutable = url.pathname.startsWith('/assets/')
|
|
173
|
+
return send(200, TYPES[extname(path)] ?? 'application/octet-stream', readFileSync(path), {
|
|
174
|
+
'cache-control': immutable ? 'public, max-age=31536000, immutable' : 'no-cache',
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
// Every other path is a page of the app.
|
|
178
|
+
return send(200, TYPES['.html'], index(), { 'cache-control': 'no-cache' })
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
182
|
+
for (const v of ['PDSLS_PUBLIC_URL', 'PDSLS_DOMAIN', 'PDSLS_PLC_URL']) if (!env[v]) throw new Error(`set ${v}`)
|
|
183
|
+
index()
|
|
184
|
+
http
|
|
185
|
+
.createServer((req, res) =>
|
|
186
|
+
serve(req, res).catch((err) => {
|
|
187
|
+
console.error('pdsls:', req.url, err.message)
|
|
188
|
+
if (!res.headersSent) res.writeHead(500).end()
|
|
189
|
+
}),
|
|
190
|
+
)
|
|
191
|
+
.listen(Number(BIND_PORT), BIND_HOST, () => console.log(`pdsls: listening on ${BIND_HOST}:${BIND_PORT} for ${DOMAIN} at ${PUBLIC_URL}`))
|
|
192
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# The upstream relay, rebuilt with allow-private-hosts.patch.
|
|
2
|
+
#
|
|
3
|
+
# Upstream's relay refuses loopback and private addresses in two places, with no
|
|
4
|
+
# setting to turn either off: the host check (an SSRF-safe transport) and the
|
|
5
|
+
# firehose dialer for wss:// hosts (an SSRF-safe dialer). Its admin requestCrawl
|
|
6
|
+
# accepts localhost:<port>, but the host check then fails, so a stock relay image can
|
|
7
|
+
# never crawl a snowglobe PDS; and a PDS at an https name behind a local TLS proxy
|
|
8
|
+
# resolves to a private address, which the dialer refuses. The patch makes both honor
|
|
9
|
+
# RELAY_ALLOW_PRIVATE_HOSTS=true (host_checker.go and slurper.go); nothing else changes.
|
|
10
|
+
#
|
|
11
|
+
# The binary is built from the same commit as the published image, which supplies
|
|
12
|
+
# everything else (admin UI, runtime layout).
|
|
13
|
+
|
|
14
|
+
ARG INDIGO_COMMIT=b2619d864df00a66455bd9f5f0c83c1ac13ad73d
|
|
15
|
+
|
|
16
|
+
FROM golang:1.26-alpine3.22 AS build
|
|
17
|
+
ARG INDIGO_COMMIT
|
|
18
|
+
RUN apk add --no-cache build-base git
|
|
19
|
+
WORKDIR /src
|
|
20
|
+
RUN git init -q \
|
|
21
|
+
&& git remote add origin https://github.com/bluesky-social/indigo.git \
|
|
22
|
+
&& git fetch -q --depth 1 origin "${INDIGO_COMMIT}" \
|
|
23
|
+
&& git checkout -q FETCH_HEAD
|
|
24
|
+
COPY allow-private-hosts.patch /tmp/
|
|
25
|
+
RUN git apply /tmp/allow-private-hosts.patch
|
|
26
|
+
ENV GOEXPERIMENT=loopvar
|
|
27
|
+
RUN go build -tags timetzdata -o /relay ./cmd/relay
|
|
28
|
+
|
|
29
|
+
FROM ghcr.io/bluesky-social/indigo:relay-${INDIGO_COMMIT}
|
|
30
|
+
COPY --from=build /relay /relay
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
diff --git a/cmd/relay/relay/host_checker.go b/cmd/relay/relay/host_checker.go
|
|
2
|
+
index 251ff2c..1142778 100644
|
|
3
|
+
--- a/cmd/relay/relay/host_checker.go
|
|
4
|
+
+++ b/cmd/relay/relay/host_checker.go
|
|
5
|
+
@@ -4,6 +4,7 @@ import (
|
|
6
|
+
"context"
|
|
7
|
+
"fmt"
|
|
8
|
+
"net/http"
|
|
9
|
+
+ "os"
|
|
10
|
+
"time"
|
|
11
|
+
|
|
12
|
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
|
|
13
|
+
@@ -33,9 +34,16 @@ func NewHostClient(userAgent string) *HostClient {
|
|
14
|
+
if userAgent == "" {
|
|
15
|
+
userAgent = "indigo-relay (atproto-relay)"
|
|
16
|
+
}
|
|
17
|
+
+ // RELAY_ALLOW_PRIVATE_HOSTS=true lets a local network's relay check hosts on
|
|
18
|
+
+ // localhost and private addresses. Without it, an admin requestCrawl that names
|
|
19
|
+
+ // localhost:<port> is accepted and then fails the host check.
|
|
20
|
+
+ transport := ssrf.PublicOnlyTransport()
|
|
21
|
+
+ if os.Getenv("RELAY_ALLOW_PRIVATE_HOSTS") == "true" {
|
|
22
|
+
+ transport = http.DefaultTransport.(*http.Transport).Clone()
|
|
23
|
+
+ }
|
|
24
|
+
c := http.Client{
|
|
25
|
+
Timeout: 5 * time.Second,
|
|
26
|
+
- Transport: ssrf.PublicOnlyTransport(),
|
|
27
|
+
+ Transport: transport,
|
|
28
|
+
}
|
|
29
|
+
return &HostClient{
|
|
30
|
+
Client: &c,
|
|
31
|
+
diff --git a/cmd/relay/relay/slurper.go b/cmd/relay/relay/slurper.go
|
|
32
|
+
index 67bc2cb..0b28733 100644
|
|
33
|
+
--- a/cmd/relay/relay/slurper.go
|
|
34
|
+
+++ b/cmd/relay/relay/slurper.go
|
|
35
|
+
@@ -7,6 +7,7 @@ import (
|
|
36
|
+
"log/slog"
|
|
37
|
+
"math/rand"
|
|
38
|
+
"net/http"
|
|
39
|
+
+ "os"
|
|
40
|
+
"strings"
|
|
41
|
+
"sync"
|
|
42
|
+
"sync/atomic"
|
|
43
|
+
@@ -297,8 +298,10 @@ func (s *Slurper) subscribeWithRedialer(ctx context.Context, host *models.Host,
|
|
44
|
+
HandshakeTimeout: time.Second * 5,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
- // if this isn't a localhost / private connection, then we should enable SSRF protections
|
|
48
|
+
- if !host.NoSSL {
|
|
49
|
+
+ // if this isn't a localhost / private connection, then we should enable SSRF protections.
|
|
50
|
+
+ // RELAY_ALLOW_PRIVATE_HOSTS=true skips them, so a local network's relay can dial wss://
|
|
51
|
+
+ // hosts whose names resolve to private addresses (a snowglobe's PDSes behind a TLS proxy).
|
|
52
|
+
+ if !host.NoSSL && os.Getenv("RELAY_ALLOW_PRIVATE_HOSTS") != "true" {
|
|
53
|
+
netDialer := ssrf.PublicOnlyDialer()
|
|
54
|
+
d.NetDialContext = netDialer.DialContext
|
|
55
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Slingshot (microcosm-rs), built from a pinned commit. Upstream publishes no image.
|
|
2
|
+
#
|
|
3
|
+
# Slingshot is the handle and record cache apps ask for a handle's DID and PDS
|
|
4
|
+
# (com.bad-example.identity.resolveMiniDoc). It takes its directory and its feed as
|
|
5
|
+
# settings (SLINGSHOT_PLC_DIRECTORY, SLINGSHOT_JETSTREAM), which is what lets it serve
|
|
6
|
+
# this network instead of the public one.
|
|
7
|
+
#
|
|
8
|
+
# The build follows microcosm-rs's own slingshot/Dockerfile, except that the source comes
|
|
9
|
+
# from the pinned commit rather than a build context.
|
|
10
|
+
|
|
11
|
+
ARG MICROCOSM_COMMIT=3137b07d5268812d2de0f3177664be92196994d8
|
|
12
|
+
|
|
13
|
+
FROM rust:1.96-bookworm AS build
|
|
14
|
+
ARG MICROCOSM_COMMIT
|
|
15
|
+
# aws-lc-sys builds a C library with cmake and bindgen (clang); reqwest's native-tls
|
|
16
|
+
# and the jetstream client need the OpenSSL headers.
|
|
17
|
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
18
|
+
clang libclang-dev build-essential cmake libssl-dev pkg-config git \
|
|
19
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
20
|
+
WORKDIR /src
|
|
21
|
+
RUN git init -q \
|
|
22
|
+
&& git remote add origin https://tangled.org/jola.dev/microcosm-rs \
|
|
23
|
+
&& git fetch -q --depth 1 origin "${MICROCOSM_COMMIT}" \
|
|
24
|
+
&& git checkout -q FETCH_HEAD
|
|
25
|
+
RUN cargo build --release -p slingshot
|
|
26
|
+
|
|
27
|
+
FROM debian:12.14-slim
|
|
28
|
+
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates libssl3 \
|
|
29
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
30
|
+
COPY --from=build /src/target/release/slingshot /usr/local/bin/slingshot
|
|
31
|
+
# slingshot serves ./static/index.html relative to its working directory.
|
|
32
|
+
WORKDIR /app
|
|
33
|
+
COPY --from=build /src/slingshot/static ./static
|
|
34
|
+
RUN mkdir -p /data
|
|
35
|
+
VOLUME ["/data"]
|
|
36
|
+
ENTRYPOINT ["slingshot"]
|