@openmeet/snowglobe 0.0.0-stage → 0.3.0-rc.1

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.
Files changed (54) hide show
  1. package/.env.example +28 -0
  2. package/LICENSE +21 -0
  3. package/README.md +148 -2
  4. package/bin/snowglobe.mjs +71 -0
  5. package/lib/apps.mjs +97 -0
  6. package/lib/args.mjs +29 -0
  7. package/lib/certs.mjs +211 -0
  8. package/lib/commands/account.mjs +85 -0
  9. package/lib/commands/app.mjs +86 -0
  10. package/lib/commands/certs.mjs +36 -0
  11. package/lib/commands/check.mjs +37 -0
  12. package/lib/commands/down.mjs +40 -0
  13. package/lib/commands/env.mjs +146 -0
  14. package/lib/commands/invite.mjs +48 -0
  15. package/lib/commands/lexicons.mjs +94 -0
  16. package/lib/commands/ls.mjs +72 -0
  17. package/lib/commands/render.mjs +63 -0
  18. package/lib/commands/run.mjs +109 -0
  19. package/lib/commands/trust.mjs +45 -0
  20. package/lib/commands/up.mjs +301 -0
  21. package/lib/config.mjs +514 -0
  22. package/lib/env.mjs +526 -0
  23. package/lib/hosts.mjs +36 -0
  24. package/lib/prebuilt.mjs +56 -0
  25. package/lib/render.mjs +601 -0
  26. package/lib/settings.mjs +156 -0
  27. package/lib/state.mjs +109 -0
  28. package/lib/trust.mjs +288 -0
  29. package/lib/up.mjs +54 -0
  30. package/lib/xrpc.mjs +49 -0
  31. package/package.json +55 -4
  32. package/snowglobe.default.yml +54 -0
  33. package/stack/docker-compose.relay.yml +105 -0
  34. package/stack/docker-compose.test.yml +41 -0
  35. package/stack/docker-compose.yml +179 -0
  36. package/stack/https/app-names.cjs +31 -0
  37. package/stack/https/dns/Dockerfile +10 -0
  38. package/stack/https/dns/dns.sh +40 -0
  39. package/stack/https/nginx.conf.template +104 -0
  40. package/stack/https/proxy/Dockerfile +8 -0
  41. package/stack/https/proxy/server.mjs +213 -0
  42. package/stack/init/init.sh +118 -0
  43. package/stack/page/fonts/OFL-jetbrains-mono.txt +93 -0
  44. package/stack/page/fonts/OFL-schibsted-grotesk.txt +93 -0
  45. package/stack/page/fonts/jetbrains-mono-latin.woff2 +0 -0
  46. package/stack/page/fonts/schibsted-grotesk-latin.woff2 +0 -0
  47. package/stack/page/index.html +409 -0
  48. package/stack/page/snowglobe.conf +59 -0
  49. package/stack/pdsls/Dockerfile +27 -0
  50. package/stack/pdsls/network.patch +294 -0
  51. package/stack/pdsls/server.mjs +192 -0
  52. package/stack/relay/Dockerfile +30 -0
  53. package/stack/relay/allow-private-hosts.patch +55 -0
  54. package/stack/slingshot/Dockerfile +36 -0
package/lib/render.mjs ADDED
@@ -0,0 +1,601 @@
1
+ // The https globe a checked snowglobe.yml describes, as files: a compose file with every
2
+ // service, nginx's servers for the globe's names, the leaf certificate's names and the
3
+ // facts the snowglobe commands read. Pure: a globe (from config.mjs) and the base
4
+ // compose files in, file texts out. bin/snowglobe.mjs render writes them.
5
+ //
6
+ // Each PDS is a plain service on the compose network, pds-<name>, reached by that name, and so
7
+ // is each group host, grouphost-<name>. Nothing shares another's network namespace except
8
+ // Slingshot, which runs in slingshot-dns's for its DNS. Inside the network nginx also answers to
9
+ // every PDS's and group host's https name, so they, the relay and TAP reach each other over TLS
10
+ // as on the real network.
11
+ //
12
+ // The services the plain http stacks share (PLC, Postgres, maildev, Jetstream, TAP, init,
13
+ // the relay) start from their definitions in stack/docker-compose.yml, stack/docker-compose.test.yml and
14
+ // stack/docker-compose.relay.yml, so a change there reaches the globe too. What only the https
15
+ // globe runs is defined here.
16
+
17
+ import { createHash } from 'node:crypto';
18
+ import { readdirSync, readFileSync } from 'node:fs';
19
+ import { dirname, isAbsolute, join } from 'node:path';
20
+ import { parse, stringify } from 'yaml';
21
+ import { IMAGE_NAMES, RESERVED } from './config.mjs';
22
+ import { lookup, parseValue } from './settings.mjs';
23
+
24
+ // Every PDS listens on this port inside its own container, and every group host on the other.
25
+ export const PDS_PORT = 3000;
26
+ export const GROUPHOST_PORT = 7070;
27
+
28
+ const APP_NAMES = './stack/https/app-names.cjs:/https/app-names.cjs:ro';
29
+ const NETWORK = 'snowglobe';
30
+ const healthy = { condition: 'service_healthy' };
31
+
32
+ /**
33
+ * A short hash of the files an image is built from (named by the caller: its Dockerfile and what
34
+ * that COPYs), so the image is tagged by its source: a changed file is a new tag, which compose
35
+ * builds, where a fixed tag it already has would be reused stale. Files the build never reads
36
+ * (editor lock files, .DS_Store, tests) cannot change the tag or stop a render.
37
+ */
38
+ export function contextHash(root, dir, files) {
39
+ const h = createHash('sha256');
40
+ for (const f of [...files].sort()) h.update(`${f}\0`).update(readFileSync(join(root, dir, f))).update('\0');
41
+ return h.digest('hex').slice(0, 12);
42
+ }
43
+
44
+ // Each image snowglobe builds from its own folder: its folder and the files its Dockerfile uses.
45
+ const BUILT = {
46
+ 'snowglobe-dns': ['stack/https/dns', ['Dockerfile', 'dns.sh']],
47
+ 'snowglobe-proxy': ['stack/https/proxy', ['Dockerfile', 'server.mjs']],
48
+ 'snowglobe-pdsls': ['stack/pdsls', ['Dockerfile', 'network.patch', 'server.mjs']],
49
+ };
50
+
51
+ /** The base compose files' services, which the globe's shared services start from. */
52
+ export function loadBase(root) {
53
+ // logLevel: the http overlays' !reset and !override tags are on services not read here.
54
+ const services = (f) => parse(readFileSync(join(root, f), 'utf8'), { logLevel: 'error' }).services;
55
+ return {
56
+ base: services('stack/docker-compose.yml'),
57
+ test: services('stack/docker-compose.test.yml'),
58
+ relay: services('stack/docker-compose.relay.yml'),
59
+ // The tags of the images snowglobe builds from its own folders (see contextHash).
60
+ tags: Object.fromEntries(Object.entries(BUILT).map(([k, [dir, files]]) => [k, `${k}:${contextHash(root, dir, files)}`])),
61
+ };
62
+ }
63
+
64
+ /** TAP's settings from services.tap's map: where it sends events and which collection it follows. */
65
+ function tapSettings(globe) {
66
+ const t = globe.tap;
67
+ if (!t) return {};
68
+ const webhook = t.webhook && parseValue(t.webhook).parts.map((x) => (x.text !== undefined ? x.text : lookup(globe, x.name).value)).join('');
69
+ return {
70
+ ...(webhook && { TAP_WEBHOOK_URL: webhook }),
71
+ ...(t.signal_collection && { TAP_SIGNAL_COLLECTION: t.signal_collection }),
72
+ ...(t.collection_filters.length && { TAP_COLLECTION_FILTERS: t.collection_filters.join(',') }),
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Render a checked globe. stateDir is the snowglobe's state folder (an absolute path): the
78
+ * compose file mounts the CA (in ca/ beside it), the leaf and nginx's routes from it, and init
79
+ * writes the logins into it. globeDir is where the files will be, an absolute path, or relative
80
+ * to the compose project directory (the repo root), because the compose file mounts nginx's part
81
+ * of it. Returns { '<path under globeDir>': text }.
82
+ */
83
+ export function renderGlobe(globe, base, { stateDir, globeDir = join(stateDir ?? '', 'globe'), source = 'snowglobe.yml' } = {}) {
84
+ if (!stateDir || !isAbsolute(stateDir)) throw new Error('renderGlobe needs stateDir, the snowglobe\'s state folder, as an absolute path');
85
+ const https = (f) => join(stateDir, 'https', f);
86
+ const caFile = join(dirname(stateDir), 'ca', 'ca.crt');
87
+ const CA = `${caFile}:/https/ca.crt:ro`;
88
+ // Go reads every file in /etc/ssl/certs on top of its bundle, so the public roots stay.
89
+ const GO_CA = `${caFile}:/etc/ssl/certs/snowglobe-ca.pem:ro`;
90
+ const D = globe.domain;
91
+ const svc = (p) => `pds-${p.name}`;
92
+ const host = (p) => `${p.name}.${D}`;
93
+ const handleDomain = (p) => (p.bare_handles ? `.${D}` : `.${p.name}.${D}`);
94
+ const handleWildcard = (p) => `*${handleDomain(p)}`;
95
+ // A group host is <name>.<domain>, and its groups' handles are <group>.<name>.<domain>.
96
+ const ghSvc = (g) => `grouphost-${g.name}`;
97
+ const ghUrl = (g) => `http://${ghSvc(g)}:${GROUPHOST_PORT}`;
98
+ const unsourced = globe.grouphosts.filter((g) => !g.image && !g.build).map((g) => g.name);
99
+ if (unsourced.length) throw new Error(`no image or build folder for the group host(s) ${unsourced.join(', ')} (config.mjs withGroupHostSources)`);
100
+ // init seeds alice, bob and the invite code on the PDS with bare handles, else the first.
101
+ const seed = globe.pds.find((p) => p.bare_handles) ?? globe.pds[0];
102
+ const on = globe.services;
103
+ // Where Jetstream and TAP read: the relay, or with one PDS that PDS's own firehose.
104
+ const feed = on.relay ? 'http://relay:2470' : `http://${svc(globe.pds[0])}:${PDS_PORT}`;
105
+ const copy = (o) => structuredClone(o);
106
+
107
+ const services = {};
108
+
109
+ services.postgres = {
110
+ ...copy(base.test.postgres),
111
+ // The PLC's database, named, so a `down` without -v keeps every DID.
112
+ volumes: ['plc-db:/var/lib/postgresql/data'],
113
+ };
114
+ delete services.postgres.ports;
115
+
116
+ services.maildev = {
117
+ ...copy(base.test.maildev),
118
+ ports: [
119
+ '${SG_BIND:-127.0.0.1}:${SG_MAILDEV_WEB_PORT:-1081}:1080',
120
+ '${SG_BIND:-127.0.0.1}:${SG_MAILDEV_SMTP_PORT:-1026}:1025',
121
+ ],
122
+ };
123
+
124
+ services.plc = {
125
+ ...copy(base.base.plc),
126
+ depends_on: { postgres: healthy },
127
+ ports: ['${SG_BIND:-127.0.0.1}:${SG_PLC_PORT:-2582}:2582'],
128
+ };
129
+
130
+ const pdsBase = base.base.pds;
131
+ for (const p of globe.pds) {
132
+ services[svc(p)] = {
133
+ image: p.image,
134
+ // Release images run as root; monorepo images run as `node`, which cannot open the
135
+ // root-owned data volume.
136
+ user: 'root',
137
+ restart: 'unless-stopped',
138
+ depends_on: { plc: healthy },
139
+ environment: {
140
+ // The AppView, report service, secrets and logging as docker-compose.yml has them.
141
+ ...copy(pdsBase.environment),
142
+ PDS_PORT: String(PDS_PORT),
143
+ PDS_HOSTNAME: host(p),
144
+ PDS_SERVICE_DID: `did:web:${host(p)}`,
145
+ PDS_SERVICE_HANDLE_DOMAINS: handleDomain(p),
146
+ PDS_HANDLE_DOMAINS: handleDomain(p),
147
+ PDS_INVITE_REQUIRED: p.invites === 'required' ? 'true' : 'false',
148
+ PDS_EMAIL_SMTP_URL: 'smtp://maildev:1025',
149
+ PDS_EMAIL_FROM_ADDRESS: `noreply@${D}`,
150
+ PDS_LEXICON_AUTHORITY_DID: '${SG_LEXICON_AUTHORITY_DID:-}',
151
+ // The public URL is https, so each PDS runs as it would on the real network.
152
+ PDS_DEV_MODE: 'false',
153
+ // Every address a PDS fetches from here (nginx, the PLC, this machine) is an RFC
154
+ // 1918 one, which the SSRF-protected fetch would refuse.
155
+ PDS_DISABLE_SSRF_PROTECTION: 'true',
156
+ NODE_EXTRA_CA_CERTS: '/https/ca.crt',
157
+ // <name>.app.<domain> resolves to nginx inside each PDS (https/app-names.cjs).
158
+ SG_RESOLVE_DOMAIN: `app.${D}`,
159
+ NODE_OPTIONS: '--require /https/app-names.cjs',
160
+ },
161
+ volumes: [`${svc(p)}-data:/pds/data`, CA, APP_NAMES],
162
+ networks: [NETWORK],
163
+ healthcheck: { ...copy(pdsBase.healthcheck), test: ['CMD', 'wget', '-q', '--spider', `http://127.0.0.1:${PDS_PORT}/xrpc/_health`] },
164
+ };
165
+ }
166
+
167
+ // Group hosts, of kind opensocial (atproto-group-host): @atproto/pds run as a library, with
168
+ // the group routes on top, so its settings are a PDS's. It makes its groups' accounts itself,
169
+ // so sign-up takes no code. A second kind would render its own service here.
170
+ const pick = (o, keys) => Object.fromEntries(keys.map((k) => [k, o[k]]));
171
+ for (const g of globe.grouphosts) {
172
+ services[ghSvc(g)] = {
173
+ // Built from the folder (a checkout, with its own Dockerfile) on every up; compose names
174
+ // the image after the project and the service.
175
+ ...(g.image ? { image: g.image } : { build: { context: g.build } }),
176
+ user: 'root',
177
+ restart: 'unless-stopped',
178
+ depends_on: { plc: healthy },
179
+ environment: {
180
+ // The AppView, report service and secrets as every PDS here has them.
181
+ ...pick(pdsBase.environment, [
182
+ 'PDS_BSKY_APP_VIEW_URL', 'PDS_BSKY_APP_VIEW_DID', 'PDS_REPORT_SERVICE_URL', 'PDS_REPORT_SERVICE_DID',
183
+ 'PDS_ADMIN_PASSWORD', 'PDS_JWT_SECRET', 'PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX',
184
+ ]),
185
+ PDS_PORT: String(GROUPHOST_PORT),
186
+ PDS_HOSTNAME: host(g),
187
+ PDS_SERVICE_HANDLE_DOMAINS: `.${host(g)}`,
188
+ PDS_DATA_DIRECTORY: '/data',
189
+ PDS_BLOBSTORE_DISK_LOCATION: '/data/blocks',
190
+ PDS_DID_PLC_URL: 'http://plc:2582',
191
+ PDS_CRAWLERS: '',
192
+ PDS_INVITE_REQUIRED: 'false',
193
+ PDS_LEXICON_AUTHORITY_DID: '${SG_LEXICON_AUTHORITY_DID:-}',
194
+ PDS_DEV_MODE: 'false',
195
+ PDS_DISABLE_SSRF_PROTECTION: 'true',
196
+ LOG_ENABLED: 'true',
197
+ LOG_LEVEL: 'info',
198
+ NODE_ENV: 'development',
199
+ // Its own URL (and so its did:web), how it calls its own PDS, and the PLC it reads.
200
+ HOST_PUBLIC_URL: `https://${host(g)}`,
201
+ HOST_LOOPBACK_URL: `http://127.0.0.1:${GROUPHOST_PORT}`,
202
+ HOST_PLC_URL: 'http://plc:2582',
203
+ NODE_EXTRA_CA_CERTS: '/https/ca.crt',
204
+ // Every name under the domain, its members' handles too, resolves to nginx.
205
+ NODE_OPTIONS: '--require /https/app-names.cjs',
206
+ SG_RESOLVE_DOMAIN: D,
207
+ },
208
+ volumes: [`${ghSvc(g)}-data:/data`, CA, APP_NAMES],
209
+ networks: [NETWORK],
210
+ healthcheck: {
211
+ test: ['CMD', 'node', '-e', `fetch('http://127.0.0.1:${GROUPHOST_PORT}/xrpc/_health').then((r) => process.exit(r.ok ? 0 : 1), () => process.exit(1))`],
212
+ interval: '5s',
213
+ timeout: '5s',
214
+ retries: 10,
215
+ },
216
+ };
217
+ }
218
+
219
+ if (on.relay) {
220
+ const relay = copy(base.relay.relay);
221
+ delete relay.network_mode;
222
+ // Every host is a wss:// one.
223
+ delete relay.environment.RELAY_ALLOW_INSECURE_HOSTS;
224
+ services.relay = {
225
+ ...relay,
226
+ // Its own tag, so an image built from an older relay/ patch is never reused.
227
+ image: 'snowglobe-relay:https-${SG_RELAY_INDIGO_COMMIT:-b2619d864df00a66455bd9f5f0c83c1ac13ad73d}',
228
+ depends_on: { nginx: healthy },
229
+ volumes: [...relay.volumes, GO_CA],
230
+ networks: [NETWORK],
231
+ };
232
+ // The relay adds a host only through its admin API, so a PDS cannot register itself with
233
+ // requestCrawl. Bare names are https/wss hosts to the relay.
234
+ services['relay-init'] = {
235
+ image: 'alpine:3.20',
236
+ depends_on: { relay: healthy, nginx: healthy, ...Object.fromEntries(globe.pds.map((p) => [svc(p), healthy])) },
237
+ environment: {
238
+ RELAY_ADMIN_PASSWORD: base.relay.relay.environment.RELAY_ADMIN_PASSWORD,
239
+ RELAY_HOSTS: globe.pds.map(host).join(' '),
240
+ },
241
+ entrypoint: '/bin/sh',
242
+ command: [
243
+ '-c',
244
+ [
245
+ 'set -eu',
246
+ 'apk add --no-cache curl >/dev/null 2>&1',
247
+ 'for host in $$RELAY_HOSTS; do',
248
+ ' echo "[relay-init] crawl $$host"',
249
+ ' curl -sf -u "admin:$$RELAY_ADMIN_PASSWORD" -H "Content-Type: application/json" \\',
250
+ ' -d "{\\"hostname\\": \\"$$host\\"}" http://relay:2470/admin/pds/requestCrawl',
251
+ ' echo',
252
+ 'done',
253
+ '',
254
+ ].join('\n'),
255
+ ],
256
+ networks: [NETWORK],
257
+ restart: 'no',
258
+ };
259
+ }
260
+
261
+ if (on.jetstream) {
262
+ services.jetstream = {
263
+ ...copy(base.base.jetstream),
264
+ depends_on: on.relay ? { relay: healthy } : { [svc(globe.pds[0])]: healthy },
265
+ environment: {
266
+ ...copy(base.base.jetstream.environment),
267
+ JETSTREAM_WS_URL: `${feed.replace(/^http/, 'ws')}/xrpc/com.atproto.sync.subscribeRepos`,
268
+ },
269
+ ports: [
270
+ '${SG_BIND:-127.0.0.1}:${SG_JETSTREAM_PORT:-6008}:6008',
271
+ '${SG_BIND:-127.0.0.1}:${SG_JETSTREAM_METRICS_PORT:-6009}:6009',
272
+ ],
273
+ };
274
+ }
275
+
276
+ if (on.tap) {
277
+ // TAP runs in tap-dns's network namespace, whose DNS answers every name under the domain with
278
+ // nginx: so it reaches the webhook's app and checks a handle at its https name. It follows the
279
+ // stream and finds an account when it writes in the signal collection; its crawl of the
280
+ // collection's repos is refused by the PDS (HTTP 401) and finds none.
281
+ services.tap = {
282
+ ...copy(base.base.tap),
283
+ depends_on: on.relay ? { relay: healthy } : { [svc(globe.pds[0])]: healthy },
284
+ environment: { ...copy(base.base.tap.environment), TAP_RELAY_URL: feed, ...tapSettings(globe) },
285
+ volumes: [...base.base.tap.volumes, GO_CA],
286
+ network_mode: 'service:tap-dns',
287
+ depends_on: { 'tap-dns': { condition: 'service_started' }, ...(on.relay ? { relay: healthy } : { [svc(globe.pds[0])]: healthy }) },
288
+ };
289
+ delete services.tap.networks;
290
+ delete services.tap.ports;
291
+ }
292
+
293
+ // init seeds alice and bob (data/accounts.env) on the seed PDS, under its handle domain.
294
+ // init.sh names http://pds:3000, so it is pointed at the seed PDS's service.
295
+ services.init = {
296
+ ...copy(base.base.init),
297
+ depends_on: { [svc(seed)]: healthy, plc: healthy },
298
+ environment: {
299
+ ...copy(base.base.init.environment),
300
+ PDS_HOSTNAME: host(seed),
301
+ HANDLE_DOMAIN: handleDomain(seed),
302
+ SG_DOMAIN: D,
303
+ },
304
+ // The logins init writes go to the snowglobe's state folder, not the checkout's data/.
305
+ volumes: ['./stack/init:/scripts:ro', `${stateDir}:/devnet-data`],
306
+ entrypoint: '/bin/sh',
307
+ command: ['-c', `sed "s#http://pds:3000#http://${svc(seed)}:${PDS_PORT}#" /scripts/init.sh > /tmp/init.sh && sh /tmp/init.sh`],
308
+ };
309
+
310
+ services.nginx = {
311
+ image: 'nginx:1.30-alpine',
312
+ restart: 'unless-stopped',
313
+ depends_on: { plc: healthy, ...Object.fromEntries(globe.pds.map((p) => [svc(p), healthy])) },
314
+ ports: ['${SG_BIND:-127.0.0.1}:443:443'],
315
+ extra_hosts: [
316
+ // This machine, where an app registered with bin/snowglobe.mjs app listens.
317
+ 'host.docker.internal:host-gateway',
318
+ ],
319
+ environment: {
320
+ // The image fills ${SG_DOMAIN} into the template at start, and only names starting
321
+ // SG_, so nginx's own $host and the like are left alone.
322
+ SG_DOMAIN: D,
323
+ NGINX_ENVSUBST_FILTER: '^SG_',
324
+ },
325
+ volumes: [
326
+ './stack/https/nginx.conf.template:/etc/nginx/templates/default.conf.template:ro',
327
+ `${https('leaf.crt')}:/etc/nginx/https/leaf.crt:ro`,
328
+ `${https('leaf.key')}:/etc/nginx/https/leaf.key:ro`,
329
+ // The app routes and the page's snowglobe.json, which bin/snowglobe.mjs app writes (a
330
+ // directory, so a rewrite shows up).
331
+ `${https('nginx')}:/etc/nginx/snowglobe:ro`,
332
+ // The globe's own servers and the page's relays to its PDSes, rendered here.
333
+ `${isAbsolute(globeDir) ? globeDir : `./${globeDir}`}/nginx:/etc/nginx/globe:ro`,
334
+ // The network's page, the one a hosted globe serves, and its relays.
335
+ './stack/page/index.html:/srv/page/index.html:ro',
336
+ './stack/page/fonts:/srv/page/fonts:ro',
337
+ './stack/page/snowglobe.conf:/etc/nginx/page/snowglobe.conf:ro',
338
+ ],
339
+ networks: {
340
+ // Inside the network every PDS's and group host's https name is nginx, so they, the relay
341
+ // and TAP reach each other over TLS at their public URLs.
342
+ [NETWORK]: { aliases: [...globe.pds.map(host), ...globe.grouphosts.map(host)] },
343
+ },
344
+ healthcheck: { test: ['CMD', 'nginx', '-t', '-q'], interval: '5s', timeout: '3s', retries: 10 },
345
+ };
346
+
347
+ if (on.tap) {
348
+ services['tap-dns'] = {
349
+ build: './stack/https/dns',
350
+ image: base.tags['snowglobe-dns'],
351
+ restart: 'unless-stopped',
352
+ environment: { SG_DOMAIN: D },
353
+ dns: ['127.0.0.1'],
354
+ depends_on: { nginx: healthy },
355
+ ports: ['${SG_BIND:-127.0.0.1}:${SG_TAP_PORT:-2480}:2480'],
356
+ networks: [NETWORK],
357
+ };
358
+ }
359
+
360
+ if (on.slingshot) {
361
+ // Slingshot (microcosm-rs) answers com.bad-example.identity.resolveMiniDoc, a handle's DID
362
+ // and PDS, from this globe's PLC and Jetstream. It reaches the handles' names over TLS,
363
+ // which needs the CA as its only root and a DNS that answers any name under the domain:
364
+ // slingshot-dns, whose network namespace it runs in, so nginx reaches it as slingshot-dns:8080.
365
+ services['slingshot-dns'] = {
366
+ build: './stack/https/dns',
367
+ image: base.tags['snowglobe-dns'],
368
+ restart: 'unless-stopped',
369
+ environment: { SG_DOMAIN: D },
370
+ dns: ['127.0.0.1'],
371
+ depends_on: { nginx: healthy },
372
+ networks: [NETWORK],
373
+ };
374
+ services.slingshot = {
375
+ // SG_SLINGSHOT_COMMIT is a full microcosm-rs commit: it picks what is built and names
376
+ // the image, so the tag is always the source.
377
+ build: {
378
+ context: './stack/slingshot',
379
+ args: { MICROCOSM_COMMIT: '${SG_SLINGSHOT_COMMIT:-3137b07d5268812d2de0f3177664be92196994d8}' },
380
+ },
381
+ image: 'snowglobe-slingshot:${SG_SLINGSHOT_COMMIT:-3137b07d5268812d2de0f3177664be92196994d8}',
382
+ restart: 'unless-stopped',
383
+ network_mode: 'service:slingshot-dns',
384
+ depends_on: { 'slingshot-dns': { condition: 'service_started' }, plc: healthy, jetstream: healthy },
385
+ environment: {
386
+ SLINGSHOT_PLC_DIRECTORY: 'http://plc:2582',
387
+ SLINGSHOT_JETSTREAM: 'ws://jetstream:6008/subscribe',
388
+ SLINGSHOT_CACHE_DIR: '/data',
389
+ SLINGSHOT_BIND: '0.0.0.0:8080',
390
+ // The CA is the only root; the names it reaches are all the globe's.
391
+ SSL_CERT_FILE: '/https/ca.crt',
392
+ },
393
+ volumes: [CA, 'slingshot-data:/data'],
394
+ // A Docker host may start a container at 1024 open files; on one, slingshot stopped with
395
+ // "too many open files" (2026-10-20). Its own limit makes it independent of the host.
396
+ ulimits: { nofile: { soft: 65536, hard: 65536 } },
397
+ healthcheck: { test: ['CMD-SHELL', "bash -c 'echo > /dev/tcp/127.0.0.1/8080'"], interval: '5s', timeout: '3s', retries: 10 },
398
+ };
399
+ }
400
+
401
+ // A browser on another computer reaches the globe through this, with no launch flags and no
402
+ // hosts file: http://127.0.0.1:<SG_PROXY_PORT>/proxy.pac sends the globe's names here (HTTP
403
+ // CONNECT, or SOCKS5), and the proxy sends each to nginx and refuses everything else
404
+ // (https/proxy/server.mjs). Forward the port to the browser's machine.
405
+ services.proxy = {
406
+ build: './stack/https/proxy',
407
+ image: base.tags['snowglobe-proxy'],
408
+ restart: 'unless-stopped',
409
+ depends_on: { nginx: healthy },
410
+ environment: { PROXY_DOMAIN: D, PROXY_UPSTREAM: 'nginx:443' },
411
+ ports: ['${SG_BIND:-127.0.0.1}:${SG_PROXY_PORT:-1080}:1080'],
412
+ networks: [NETWORK],
413
+ healthcheck: { test: ['CMD', 'wget', '-q', '-O', '/dev/null', 'http://127.0.0.1:1080/_health'], interval: '5s', timeout: '3s', retries: 10 },
414
+ };
415
+
416
+ if (on.pdsls) {
417
+ // PDSls, the record browser apps link to, at pdsls.<domain>: built from a pinned commit with
418
+ // pdsls/network.patch and given this globe's addresses by pdsls/server.mjs, which also answers
419
+ // its handle and did:web lookups through nginx, for names under the domain only.
420
+ services.pdsls = {
421
+ build: './stack/pdsls',
422
+ image: base.tags['snowglobe-pdsls'],
423
+ restart: 'unless-stopped',
424
+ depends_on: { nginx: healthy },
425
+ environment: {
426
+ PDSLS_PUBLIC_URL: `https://pdsls.${D}`,
427
+ PDSLS_DOMAIN: D,
428
+ PDSLS_PLC_URL: `https://plc.${D}`,
429
+ PDSLS_JETSTREAM_URL: on.jetstream ? `wss://jetstream.${D}/subscribe` : '',
430
+ PDSLS_FIREHOSE_URL: on.relay ? `wss://relay.${D}` : `wss://${host(globe.pds[0])}`,
431
+ PDSLS_LEXICON_AUTHORITY: '${SG_LEXICON_AUTHORITY_DID:-}',
432
+ PDSLS_UPSTREAM: 'nginx:443',
433
+ NODE_EXTRA_CA_CERTS: '/https/ca.crt',
434
+ },
435
+ volumes: [CA],
436
+ networks: [NETWORK],
437
+ healthcheck: { test: ['CMD', 'wget', '-q', '-O', '/dev/null', 'http://127.0.0.1:8080/_health'], interval: '5s', timeout: '3s', retries: 10 },
438
+ };
439
+ }
440
+
441
+ const volumes = { 'plc-db': null };
442
+ for (const p of globe.pds) volumes[`${svc(p)}-data`] = null;
443
+ for (const g of globe.grouphosts) volumes[`${ghSvc(g)}-data`] = null;
444
+ if (on.relay) volumes['relay-data'] = null;
445
+ if (on.jetstream) volumes['jetstream-data'] = null;
446
+ if (on.tap) volumes['tap-data'] = null;
447
+ if (on.slingshot) volumes['slingshot-data'] = null;
448
+
449
+ // Services copied from the plain stack's compose files name that stack's network.
450
+ for (const s of Object.values(services)) {
451
+ if (Array.isArray(s.networks)) s.networks = s.networks.map((n) => (n === 'atproto-devnet' ? NETWORK : n));
452
+ }
453
+
454
+ const header = (c) =>
455
+ `${c} Written by bin/snowglobe.mjs render from ${source}; render again instead of editing.\n`;
456
+
457
+ const compose =
458
+ header('#') +
459
+ `# The https globe "${globe.globe}" at ${D}: ${globe.pds.map((p) => p.name).join(', ')}` +
460
+ (globe.grouphosts.length ? `; group hosts ${globe.grouphosts.map((g) => g.name).join(', ')}` : '') +
461
+ '.\n' +
462
+ '# Run it with bin/snowglobe.mjs up, which renders this file first.\n\n' +
463
+ // YAML 1.1 quoting, so a value such as restart's "no" stays a string to compose.
464
+ stringify({ services, volumes, networks: { [NETWORK]: null } }, { lineWidth: 0, nullStr: '', aliasDuplicateObjects: false, version: '1.1' });
465
+
466
+ // nginx: one server per PDS and per group host for its name and its handles, then the globe's
467
+ // services. Of two matching wildcards nginx takes the longer, so *.<pds>.<domain> wins over *.<domain>. A
468
+ // variable upstream is looked up on each request, so a recreated container is found.
469
+ const server = (names, upstream) =>
470
+ `server {\n listen 443 ssl;\n server_name ${names};\n location / {\n set $upstream ${upstream};\n proxy_pass $upstream;\n }\n}\n`;
471
+ const servers = [
472
+ ...globe.pds.map((p) => `# ${p.name}: the PDS and every handle under ${handleDomain(p).slice(1)}.\n` + server(`${host(p)} ${handleWildcard(p)}`, `http://${svc(p)}:${PDS_PORT}`)),
473
+ ...globe.grouphosts.map((g) => `# ${g.name}: the group host and every group's handle under ${host(g)}.\n` + server(`${host(g)} *.${host(g)}`, ghUrl(g))),
474
+ // The relay and the feed at https names, as a hosted globe has them, so an app's browser
475
+ // code, which cannot use plain http or the host's ports, reaches them too.
476
+ on.relay && server(`relay.${D}`, 'http://relay:2470'),
477
+ on.jetstream && server(`jetstream.${D}`, 'http://jetstream:6008'),
478
+ on.slingshot && server(`slingshot.${D}`, 'http://slingshot-dns:8080'),
479
+ on.pdsls && server(`pdsls.${D}`, 'http://pdsls:8080'),
480
+ ].filter(Boolean);
481
+ const globeConf = header('#') + '\n' + servers.join('\n');
482
+
483
+ // The page's relays to each PDS and group host (page/snowglobe.conf includes this). /health/pds
484
+ // and /people/ are the seed PDS's, as the page's snowglobe.json names them.
485
+ const u = (p) => `$pds_${p.name.replace(/-/g, '_')}`;
486
+ const ug = (g) => `$gh_${g.name.replace(/-/g, '_')}`;
487
+ const pageInc =
488
+ header('#') +
489
+ '\n' +
490
+ globe.pds.map((p) => `set ${u(p)} http://${svc(p)}:${PDS_PORT};\n`).join('') +
491
+ globe.grouphosts.map((g) => `set ${ug(g)} ${ghUrl(g)};\n`).join('') +
492
+ '\n' +
493
+ [
494
+ `location = /health/pds { proxy_pass ${u(seed)}/xrpc/_health; }`,
495
+ `location = /people/listRepos { proxy_pass ${u(seed)}/xrpc/com.atproto.sync.listRepos$is_args$args; }`,
496
+ `location = /people/describeRepo { proxy_pass ${u(seed)}/xrpc/com.atproto.repo.describeRepo$is_args$args; }`,
497
+ ...globe.pds.flatMap((p) => [
498
+ `location = /health/${p.name} { proxy_pass ${u(p)}/xrpc/_health; }`,
499
+ `location = /people/${p.name}/listRepos { proxy_pass ${u(p)}/xrpc/com.atproto.sync.listRepos$is_args$args; }`,
500
+ `location = /people/${p.name}/describeRepo { proxy_pass ${u(p)}/xrpc/com.atproto.repo.describeRepo$is_args$args; }`,
501
+ ]),
502
+ ...globe.grouphosts.flatMap((g) => [
503
+ `location = /health/${g.name} { proxy_pass ${ug(g)}/xrpc/_health; }`,
504
+ `location = /groups/${g.name}/listRepos { proxy_pass ${ug(g)}/xrpc/com.atproto.sync.listRepos$is_args$args; }`,
505
+ `location = /groups/${g.name}/describeRepo { proxy_pass ${ug(g)}/xrpc/com.atproto.repo.describeRepo$is_args$args; }`,
506
+ ]),
507
+ ].join('\n') +
508
+ '\n';
509
+
510
+ // The network page's facts (page/index.html reads them as snowglobe.json): every PDS, with its
511
+ // health relay and account list above, each group host with its groups, and each service the
512
+ // globe runs. bin/snowglobe.mjs app adds the routed apps and writes snowglobe.json.
513
+ const shortImage = (image) => {
514
+ const named = Object.entries(IMAGE_NAMES).find(([, ref]) => ref === image)?.[0];
515
+ return named ?? image.split('/').pop().replace(/@sha256:([0-9a-f]{12})[0-9a-f]+$/, '@sha256:$1…');
516
+ };
517
+ const page = {
518
+ globe: globe.globe,
519
+ domain: D,
520
+ about:
521
+ 'An atproto network on this machine: its own PDSs, PLC directory, relay and Jetstream. Its accounts live in its own directory, not the public one, so Bluesky cannot find anything here. An app is handed this network\'s addresses (snowglobe env), not Bluesky\'s.',
522
+ accountsNote:
523
+ `Every account on the network, on each of its ${globe.pds.length} PDS${globe.pds.length === 1 ? '' : 's'}` +
524
+ (globe.grouphosts.length ? `, and every group on its group host${globe.grouphosts.length === 1 ? '' : 's'}.` : '.'),
525
+ ...(on.jetstream ? { feed: '/subscribe' } : {}),
526
+ services: [
527
+ ...globe.pds.map((p) => [
528
+ 'PDS',
529
+ p.name,
530
+ p.name,
531
+ `Handles under ${handleDomain(p).slice(1)}${p.invites === 'required' ? ', by invite' : ''} (${shortImage(p.image)})`,
532
+ ]),
533
+ ...globe.grouphosts.map((g) => [
534
+ 'Group host',
535
+ g.name,
536
+ g.name,
537
+ `Holds groups, which are accounts of their own, with handles under ${host(g)} (${g.image ? shortImage(g.image) : 'built locally'})`,
538
+ ]),
539
+ ['PLC directory', 'plc', 'plc', 'Resolves each did:plc to its keys and PDS; also plc.directory under snowglobe run'],
540
+ on.relay && ['Relay', 'relay', 'relay', "Crawls each PDS and merges their changes into one firehose"],
541
+ on.jetstream && ['Jetstream', 'jetstream', 'jetstream', "The relay's firehose as JSON, for apps (and this page)"],
542
+ on.slingshot && ['Slingshot', 'slingshot', 'slingshot', 'Resolves handles, DIDs and records from this network only'],
543
+ on.tap && ['Tap', '', 'sync', 'Syncs and backfills repos'],
544
+ ['Mail', '', 'mail', 'Catches every message the PDSs send'],
545
+ on.pdsls && ['PDSls', 'pdsls', 'pdsls', "Browses this network's repos and records"],
546
+ ].filter(Boolean),
547
+ accounts: [
548
+ ...globe.pds.map((p) => [`/people/${p.name}`, `person, ${p.name}`]),
549
+ ...globe.grouphosts.map((g) => [`/groups/${g.name}`, `group, ${g.name}`]),
550
+ ],
551
+ // Links on the page before the routed apps.
552
+ apps: on.pdsls ? [{ name: 'pdsls', url: `https://pdsls.${D}` }] : [],
553
+ };
554
+
555
+ // The leaf names everything above answers to; a TLS wildcard covers one label, so each handle
556
+ // domain has its own. lib/certs.mjs adds the apps' endings.
557
+ const leafNames = [
558
+ D,
559
+ ...globe.pds.flatMap((p) => [host(p), handleWildcard(p)]),
560
+ ...globe.grouphosts.flatMap((g) => [host(g), `*.${host(g)}`]),
561
+ `plc.${D}`,
562
+ on.relay && `relay.${D}`,
563
+ on.jetstream && `jetstream.${D}`,
564
+ on.slingshot && `slingshot.${D}`,
565
+ on.pdsls && `pdsls.${D}`,
566
+ 'plc.directory',
567
+ `*.app.${D}`,
568
+ ].filter(Boolean);
569
+
570
+ // The services snowglobe up waits on: the one-shots to finish, the rest to be healthy.
571
+ const oneShots = ['init', on.relay && 'relay-init'].filter(Boolean);
572
+ const globeEnv = [
573
+ header('#').trimEnd(),
574
+ '# The globe\'s facts for the snowglobe commands. Nothing secret.',
575
+ `SG_GLOBE=${globe.globe}`,
576
+ `SG_DOMAIN=${D}`,
577
+ `SG_PDS=${globe.pds.map((p) => p.name).join(' ')}`,
578
+ `SG_SEED_PDS=${seed.name}`,
579
+ `SG_GROUPHOSTS=${globe.grouphosts.map((g) => g.name).join(' ')}`,
580
+ // The services up builds before it starts them: each group host built from a folder.
581
+ `SG_BUILDS=${globe.grouphosts.filter((g) => !g.image).map(ghSvc).join(' ')}`,
582
+ // The PDS whose handles are <name>.<domain> (empty if none), the PDSes that take an invite
583
+ // code, and every name under the domain a bare handle would take (bin/snowglobe.mjs account).
584
+ `SG_BARE_PDS=${globe.pds.find((p) => p.bare_handles)?.name ?? ''}`,
585
+ `SG_INVITE_PDS=${globe.pds.filter((p) => p.invites === 'required').map((p) => p.name).join(' ')}`,
586
+ `SG_TAKEN=${[...RESERVED, ...globe.pds.map((p) => p.name), ...globe.grouphosts.map((g) => g.name), ...globe.apps.map((a) => a.local?.handles).filter(Boolean)].join(' ')}`,
587
+ `SG_AUTHORITY_PDS=${globe.lexicons.authority}`,
588
+ `SG_ONESHOTS=${oneShots.join(' ')}`,
589
+ `SG_SERVICES=${Object.keys(services).filter((s) => !oneShots.includes(s)).join(' ')}`,
590
+ '',
591
+ ].join('\n');
592
+
593
+ return {
594
+ 'compose.yml': compose,
595
+ 'nginx/globe.conf': globeConf,
596
+ 'nginx/page.inc': pageInc,
597
+ 'leaf-names': leafNames.join('\n') + '\n',
598
+ 'globe.env': globeEnv,
599
+ 'page.json': JSON.stringify(page, null, 2) + '\n',
600
+ };
601
+ }