@openmeet/snowglobe 0.3.0 → 0.5.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/lib/config.mjs CHANGED
@@ -37,11 +37,22 @@ export const RESERVED = [
37
37
  // The lexicon authority's account, made on its PDS before any account in the file.
38
38
  const AUTHORITY_ACCOUNT = 'lex-authority';
39
39
 
40
- // The domain a file that names none gets. It is ours and its DNS points at 127.0.0.1, so a name
41
- // that escapes a globe lands on this machine, not on someone else's server. A domain under .test,
42
- // .local, .localhost, .invalid or .example would fail sign-in: a PDS refuses an OAuth client there,
43
- // and group hosts and PDSls are OAuth clients at their own names.
44
- export const DEFAULT_DOMAIN = 'local.snowglobe.at';
40
+ // The zone every local snowglobe's domain is under. It is ours and its public DNS answers every
41
+ // name under it, at any depth, with 127.0.0.1, where the name router listens (lib/router.mjs), so
42
+ // a name that escapes a globe lands on this machine, not on someone else's server. The user's CA
43
+ // vouches for names under it (lib/certs.mjs). A domain under .test, .local, .localhost, .invalid or
44
+ // .example would fail sign-in: a PDS refuses an OAuth client there, and group hosts and PDSls are
45
+ // OAuth clients at their own names.
46
+ export const LOCAL_ZONE = 'local.snowglobe.at';
47
+ // The same zone under its name from before each snowglobe had a domain of its own (0.5.0);
48
+ // snowglobe-cloud reads it to refuse a hosted globe under the local zone.
49
+ export const DEFAULT_DOMAIN = LOCAL_ZONE;
50
+
51
+ /**
52
+ * The domain a file that names none gets: one of its own, <globe>.local.snowglobe.at, so the
53
+ * name router can tell it from every other snowglobe on the machine by name alone.
54
+ */
55
+ export const localDomain = (globe) => `${globe}.${LOCAL_ZONE}`;
45
56
 
46
57
  // Names nginx answers whatever the domain: the stand-in for the real PLC directory at exactly
47
58
  // plc.directory. A domain that is it, or above it, would share it. (Apps on this machine are
@@ -131,10 +142,12 @@ export function checkGlobe(doc, { baseDir } = {}) {
131
142
  else if (typeof doc.globe !== 'string' || !GLOBE_RE.test(doc.globe))
132
143
  err('globe', `${show(doc.globe)} must be lowercase letters and digits, starting with a letter, at most 31`);
133
144
  else if (GLOBE_KEPT.includes(doc.globe)) err('globe', `"${doc.globe}" is a folder snowglobe keeps for itself; pick another name`);
134
- const domain = doc.domain ?? DEFAULT_DOMAIN;
145
+ // A globe name that is refused above gets the zone itself as a stand-in, so its error is the only one.
146
+ const named = typeof doc.globe === 'string' && GLOBE_RE.test(doc.globe);
147
+ const domain = doc.domain ?? (named ? localDomain(doc.globe) : LOCAL_ZONE);
135
148
  const domainOk = typeof domain === 'string' && DOMAIN_RE.test(domain) && domain.length <= NAME_MAX;
136
149
  if (!domainOk)
137
- err('domain', `${show(domain)} must be a domain name, such as ${DEFAULT_DOMAIN}`);
150
+ err('domain', `${show(domain)} must be a domain name, such as ${localDomain('mine')}`);
138
151
  else {
139
152
  const fixed = FIXED_DOMAINS.find(
140
153
  ({ name, wildcard }) => name === domain || name.endsWith(`.${domain}`) || (wildcard && domain.endsWith(`.${name}`)),
@@ -231,11 +244,13 @@ export function checkGlobe(doc, { baseDir } = {}) {
231
244
  }
232
245
  }
233
246
  }
234
- // Without a relay Jetstream and TAP follow one PDS's own firehose, and the others' writes
235
- // are seen by nothing.
236
- if (services.relay === 'auto') services.relay = pds.length > 1;
247
+ // Without a relay TAP follows one PDS's own firehose, and the others' writes are seen by
248
+ // nothing. Jetstream needs the relay even with one PDS: at start it asks the relay for the
249
+ // network's hosts (com.atproto.sync.listHosts), which a PDS refuses.
250
+ if (services.relay === 'auto') services.relay = pds.length > 1 || services.jetstream;
237
251
  else if (services.relay === false && pds.length > 1)
238
252
  err('services.relay', 'cannot be off with more than one PDS: only one PDS would be followed');
253
+ else if (services.relay === false && services.jetstream) err('services.jetstream', 'needs the relay');
239
254
  // Slingshot reads this network's Jetstream.
240
255
  if (services.slingshot && !services.jetstream) err('services.slingshot', 'needs jetstream');
241
256
 
package/lib/env.mjs CHANGED
@@ -5,13 +5,27 @@
5
5
 
6
6
  import { spawnSync } from 'node:child_process';
7
7
  import { randomBytes } from 'node:crypto';
8
- import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
8
+ import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
9
9
  import { request } from 'node:https';
10
10
  import net from 'node:net';
11
11
  import { homedir } from 'node:os';
12
- import { dirname, isAbsolute, join, resolve } from 'node:path';
12
+ import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
13
13
  import { fileURLToPath } from 'node:url';
14
- import { DEFAULT_DOMAIN } from './config.mjs';
14
+ import { localDomain } from './config.mjs';
15
+
16
+ // The name router's address (lib/router.mjs): public DNS answers every name under
17
+ // local.snowglobe.at with it, and the router passes each connection on to the snowglobe whose
18
+ // domain the name is under. No snowglobe it routes to publishes here.
19
+ export const ROUTER_ADDRESS = '127.0.0.1';
20
+
21
+ /**
22
+ * Whether this machine runs the name router: on Linux, unless SG_ROUTER=off. Elsewhere (and with
23
+ * it off) a new snowglobe takes the first free address from 127.0.0.1 as before 0.5.0, one that
24
+ * kept an address keeps it, and only the one on 127.0.0.1 is reached by name.
25
+ */
26
+ export function routerRuns({ root = ROOT, env = process.env, platform = process.platform } = {}) {
27
+ return platform === 'linux' && setting('SG_ROUTER', { root, env }) !== 'off';
28
+ }
15
29
 
16
30
  // A mistake in how a command was called, as opposed to one the globe answered with.
17
31
  export class UsageError extends Error {}
@@ -85,8 +99,6 @@ export function paths(root = ROOT, state) {
85
99
  admin: join(data, 'admin.env'),
86
100
  // App routes, "<name> <port> [<ending>]" per line (snowglobe app).
87
101
  apps: join(https, 'apps'),
88
- // Handles run maps to nginx, one per line (snowglobe account and up).
89
- names: join(https, 'names'),
90
102
  // The rendered globe (bin/snowglobe.mjs render). Nothing in it is secret.
91
103
  globe,
92
104
  globeEnv: join(globe, 'globe.env'),
@@ -218,7 +230,8 @@ export function setting(name, { root = ROOT, env = process.env } = {}) {
218
230
  }
219
231
 
220
232
  // The domain every globe name sits under: SG_DOMAIN from the environment or .env; else the
221
- // rendered globe's, which is the file's unless one of those replaced it; empty means DEFAULT_DOMAIN.
233
+ // rendered globe's, which is the file's unless one of those replaced it; else the globe's own,
234
+ // <name of its state folder>.local.snowglobe.at.
222
235
  // A setting that differs from the rendered globe's means the render is stale: refused, unless
223
236
  // this run is about to render again (rendering). `set` is the setting alone, for render --domain.
224
237
  export function resolveDomain({ root = ROOT, state, env = process.env, rendering = false } = {}) {
@@ -227,7 +240,7 @@ export function resolveDomain({ root = ROOT, state, env = process.env, rendering
227
240
  const rendered = envGet('SG_DOMAIN', p.globeEnv);
228
241
  if (set && rendered && set !== rendered && !rendering)
229
242
  throw new Error(`SG_DOMAIN=${set}, but the globe in ${p.globe} was rendered for ${rendered}; run snowglobe up`);
230
- const domain = set || rendered || DEFAULT_DOMAIN;
243
+ const domain = set || rendered || localDomain(basename(state));
231
244
  // A domain name and nothing else. This also refuses ${...} in .env, which compose would
232
245
  // expand and these scripts do not.
233
246
  if (/[^A-Za-z0-9.-]/.test(domain) || domain.startsWith('.') || domain.endsWith('.') || domain.includes('..'))
@@ -293,24 +306,31 @@ export function ownNginxUp(project, env = process.env) {
293
306
  * The loopback address this snowglobe publishes on, chosen once and kept in its state folder, so a
294
307
  * second snowglobe on the machine starts beside the first with no settings: it takes the first
295
308
  * 127.0.0.N that no other snowglobe in the same user folder has kept and nothing answers on 443 or any other port it publishes.
309
+ * Where the name router runs (routerRuns), that starts at 127.0.0.2: 127.0.0.1 is the router's.
296
310
  * SG_BIND, where set, wins and nothing is written. A snowglobe that is already running with no
297
- * address kept was made before this and is on 127.0.0.1, so it keeps that. Returns the address.
311
+ * address kept was made before this and is on 127.0.0.1, so it keeps that. One that kept
312
+ * 127.0.0.1 before the router existed moves to a free address when it is not running: what it
313
+ * holds depends on its domain, not its address. Returns the address.
298
314
  */
299
- export async function chooseBind({ root = ROOT, state, project, env = process.env, listening = (a) => addressBusy(a, { root, env }), ownUp = ownNginxUp } = {}) {
315
+ export async function chooseBind({ root = ROOT, state, project, env = process.env, listening = (a) => addressBusy(a, { root, env }), ownUp = ownNginxUp, platform = process.platform } = {}) {
300
316
  const p = paths(root, state);
301
- if (setting('SG_BIND', { root, env }) || existsSync(p.bind)) return resolveBind({ root, state, env });
317
+ if (setting('SG_BIND', { root, env })) return resolveBind({ root, state, env });
318
+ const routed = routerRuns({ root, env, platform });
319
+ const keep = () => existsSync(p.bind) && !(routed && resolveBind({ root, state, env }) === ROUTER_ADDRESS && !ownUp(project, env));
320
+ if (keep()) return resolveBind({ root, state, env });
302
321
  // Choosing reads the other bind files, waits on probes, then writes ours, so two first starts at
303
322
  // once would both pick the same address and keep it. One at a time, under a lock beside the state folders.
304
323
  return withLock(join(dirname(state), '.bind-lock'), async () => {
305
- if (existsSync(p.bind)) return resolveBind({ root, state, env });
306
- return pickBind({ p, state, project, env, listening, ownUp });
324
+ if (keep()) return resolveBind({ root, state, env });
325
+ return pickBind({ p, state, project, env, listening, ownUp, first: routed ? 2 : 1 });
307
326
  });
308
327
  }
309
328
 
310
329
  // Run `fn` holding a lock directory (mkdir is atomic). A lock older than `stale` ms was left by a
311
330
  // process that died. It is not taken over, since two starts taking it over at once could both
312
- // hold it; up stops and names the folder to remove.
313
- async function withLock(dir, fn, { stale = 60000, wait = 30000 } = {}) {
331
+ // hold it; up stops and names the folder to remove. `doing` says what the lock guards, for those
332
+ // messages.
333
+ export async function withLock(dir, fn, { stale = 60000, wait = 30000, doing = 'choosing an address' } = {}) {
314
334
  mkdirSync(dirname(dir), { recursive: true });
315
335
  const until = Date.now() + wait;
316
336
  for (;;) {
@@ -325,8 +345,8 @@ async function withLock(dir, fn, { stale = 60000, wait = 30000 } = {}) {
325
345
  } catch {
326
346
  continue; // released between the two calls
327
347
  }
328
- if (age > stale) throw new Error(`a snowglobe that stopped while choosing an address left ${dir}; remove that folder, then up again`);
329
- if (Date.now() > until) throw new Error(`another snowglobe is choosing an address (${dir}); remove that folder if none is`);
348
+ if (age > stale) throw new Error(`a snowglobe that stopped while ${doing} left ${dir}; remove that folder, then run the command again`);
349
+ if (Date.now() > until) throw new Error(`another snowglobe is ${doing} (${dir}); remove that folder if none is`);
330
350
  await new Promise((r) => setTimeout(r, 25));
331
351
  }
332
352
  }
@@ -337,7 +357,7 @@ async function withLock(dir, fn, { stale = 60000, wait = 30000 } = {}) {
337
357
  }
338
358
  }
339
359
 
340
- async function pickBind({ p, state, project, env, listening, ownUp }) {
360
+ async function pickBind({ p, state, project, env, listening, ownUp, first }) {
341
361
  const used = new Set();
342
362
  const top = dirname(state);
343
363
  for (const d of existsSync(top) ? readdirSync(top) : []) {
@@ -347,11 +367,11 @@ async function pickBind({ p, state, project, env, listening, ownUp }) {
347
367
  let bind = '127.0.0.1';
348
368
  if (!ownUp(project, env)) {
349
369
  bind = '';
350
- for (let n = 1; n <= 50 && !bind; n++) {
370
+ for (let n = first; n <= 50 && !bind; n++) {
351
371
  const a = `127.0.0.${n}`;
352
372
  if (!used.has(a) && !(await listening(a))) bind = a;
353
373
  }
354
- if (!bind) throw new Error('every loopback address from 127.0.0.1 to 127.0.0.50 is in use; set SG_BIND to one that is free');
374
+ if (!bind) throw new Error(`every loopback address from 127.0.0.${first} to 127.0.0.50 is in use; set SG_BIND to one that is free`);
355
375
  }
356
376
  writeFileSync(p.bind, `${bind}\n`);
357
377
  return bind;
@@ -389,14 +409,6 @@ export function loginsFile(file) {
389
409
  return file;
390
410
  }
391
411
 
392
- // Add a handle to the names run maps to nginx; a process already running under it picks a
393
- // new one up when it restarts.
394
- export function addName(host, { root = ROOT, state } = {}) {
395
- const p = paths(root, state);
396
- mkdirSync(p.https, { recursive: true });
397
- if (!lines(p.names).includes(host)) appendFileSync(p.names, `${host}\n`);
398
- }
399
-
400
412
  // The secrets a snowglobe's services start with: the PDSes' admin password, JWT secret and PLC
401
413
  // rotation key, the relay's admin password and the PLC database's. `up` makes each one random,
402
414
  // once, per snowglobe (ensureSecrets); the compose files take them as ${SG_...:?}, so none has a
package/lib/render.mjs CHANGED
@@ -46,6 +46,7 @@ const BUILT = {
46
46
  'snowglobe-dns': ['stack/https/dns', ['Dockerfile', 'dns.sh']],
47
47
  'snowglobe-proxy': ['stack/https/proxy', ['Dockerfile', 'server.mjs']],
48
48
  'snowglobe-pdsls': ['stack/pdsls', ['Dockerfile', 'network.patch', 'server.mjs']],
49
+ 'snowglobe-jetstream': ['stack/jetstream', ['Dockerfile']],
49
50
  };
50
51
 
51
52
  /** The base compose files' services, which the globe's shared services start from. */
@@ -100,7 +101,8 @@ export function renderGlobe(globe, base, { stateDir, globeDir = join(stateDir ??
100
101
  // init seeds alice, bob and the invite code on the PDS with bare handles, else the first.
101
102
  const seed = globe.pds.find((p) => p.bare_handles) ?? globe.pds[0];
102
103
  const on = globe.services;
103
- // Where Jetstream and TAP read: the relay, or with one PDS that PDS's own firehose.
104
+ // Where Jetstream and TAP read: the relay, or with one PDS and no relay, that PDS's own
105
+ // firehose (TAP only: Jetstream always has the relay, see config.mjs).
104
106
  const feed = on.relay ? 'http://relay:2470' : `http://${svc(globe.pds[0])}:${PDS_PORT}`;
105
107
  const copy = (o) => structuredClone(o);
106
108
 
@@ -154,8 +156,9 @@ export function renderGlobe(globe, base, { stateDir, globeDir = join(stateDir ??
154
156
  // 1918 one, which the SSRF-protected fetch would refuse.
155
157
  PDS_DISABLE_SSRF_PROTECTION: 'true',
156
158
  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
+ // Every name under the domain resolves to nginx inside each PDS (https/app-names.cjs):
160
+ // an app at <name>.app.<domain>, and a client the globe serves itself, such as PDSls.
161
+ SG_RESOLVE_DOMAIN: D,
159
162
  NODE_OPTIONS: '--require /https/app-names.cjs',
160
163
  },
161
164
  volumes: [`${svc(p)}-data:/pds/data`, CA, APP_NAMES],
@@ -259,13 +262,14 @@ export function renderGlobe(globe, base, { stateDir, globeDir = join(stateDir ??
259
262
  }
260
263
 
261
264
  if (on.jetstream) {
265
+ // Its first backfill asks each PDS for its repos at the PDS's https name, through nginx.
262
266
  services.jetstream = {
263
267
  ...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
- },
268
+ build: './stack/jetstream',
269
+ image: base.tags['snowglobe-jetstream'],
270
+ depends_on: { relay: healthy },
271
+ environment: { ...copy(base.base.jetstream.environment), JETSTREAM_RELAY_URL: feed },
272
+ volumes: [...base.base.jetstream.volumes, GO_CA],
269
273
  ports: [
270
274
  '${SG_BIND:-127.0.0.1}:${SG_JETSTREAM_PORT:-6008}:6008',
271
275
  '${SG_BIND:-127.0.0.1}:${SG_JETSTREAM_METRICS_PORT:-6009}:6009',
@@ -539,7 +543,7 @@ export function renderGlobe(globe, base, { stateDir, globeDir = join(stateDir ??
539
543
  g.name,
540
544
  `Holds groups, which are accounts of their own, with handles under ${host(g)} (${g.image ? shortImage(g.image) : 'built locally'})`,
541
545
  ]),
542
- ['PLC directory', 'plc', 'plc', 'Resolves each did:plc to its keys and PDS; also plc.directory under snowglobe run'],
546
+ ['PLC directory', 'plc', 'plc', 'Resolves each did:plc to its keys and PDS'],
543
547
  on.relay && ['Relay', 'relay', 'relay', "Crawls each PDS and merges their changes into one firehose"],
544
548
  on.jetstream && ['Jetstream', 'jetstream', 'jetstream', "The relay's firehose as JSON, for apps (and this page)"],
545
549
  on.slingshot && ['Slingshot', 'slingshot', 'slingshot', 'Resolves handles, DIDs and records from this network only'],
package/lib/router.mjs ADDED
@@ -0,0 +1,214 @@
1
+ // The name router: one per machine, in front of every local snowglobe on it.
2
+ //
3
+ // Each snowglobe has a domain of its own (<globe>.local.snowglobe.at unless its file names one),
4
+ // and public DNS answers every name under local.snowglobe.at, at any depth, with 127.0.0.1. The
5
+ // router listens there on 443, reads the name a TLS connection asks for (SNI) and passes the
6
+ // connection, still encrypted, to the snowglobe whose domain the name is under, on that
7
+ // snowglobe's own 127.0.0.N. Each snowglobe's nginx keeps serving its own certificate. So a browser
8
+ // reaches every snowglobe at once by name, and a handle made at any time resolves, with nothing to
9
+ // map: as on a hosted globe, where the cluster's Gateway routes by name.
10
+ //
11
+ // It is a stock nginx (the image each snowglobe's nginx runs), in a container named
12
+ // snowglobe-router on the host's network. Its folder is ~/.local/share/snowglobe/.router whatever
13
+ // XDG_DATA_HOME says, because the container serves the whole machine: a snowglobe kept in another
14
+ // state folder (a test's copy, say) is routed by the same router. In the folder: nginx.conf, written
15
+ // from routes/, one file per compose project it routes ("<domain> <address> <globe>"); compose
16
+ // project names are what is unique on a machine. up writes a snowglobe's route and down removes
17
+ // it, each under the router's lock, then brings the container in line (syncRouter).
18
+ // The longest domain wins, so a snowglobe still on the shared local.snowglobe.at keeps working
19
+ // beside ones under it. A name under no routed domain is closed. Linux only for now (routerRuns
20
+ // in lib/env.mjs); SG_ROUTER=off turns it off.
21
+
22
+ import { spawnSync } from 'node:child_process';
23
+ import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
24
+ import { homedir } from 'node:os';
25
+ import { basename, dirname, join } from 'node:path';
26
+ import { setTimeout as sleep } from 'node:timers/promises';
27
+ import { ROUTER_ADDRESS, withLock } from './env.mjs';
28
+
29
+ export const ROUTER_CONTAINER = 'snowglobe-router';
30
+ // The image each snowglobe's own nginx runs (lib/render.mjs), so the router pulls nothing new.
31
+ export const ROUTER_IMAGE = 'nginx:1.30-alpine';
32
+ // Where the router's folder is mounted in its container. A folder, not the file: up and down
33
+ // replace nginx.conf by renaming over it, and a file mount would keep the old one.
34
+ const MOUNT = '/etc/snowglobe-router';
35
+
36
+ /** The router's folder: a dot folder beside the default state folders, so no snowglobe can be named for it. */
37
+ export const routerDir = (env = process.env) => join(env.HOME || homedir(), '.local', 'share', 'snowglobe', '.router');
38
+
39
+ const ROUTE_RE = /^([a-z0-9.-]+) (127\.[0-9]+\.[0-9]+\.[0-9]+) ([a-z0-9]+)$/;
40
+
41
+ /**
42
+ * The routes in `dir`: [{ project, domain, bind, globe }], by project. A file that does not read as
43
+ * one is skipped, and so is one removed while they are read (ls reads them without the lock).
44
+ */
45
+ export function readRoutes(dir) {
46
+ const routes = join(dir, 'routes');
47
+ if (!existsSync(routes)) return [];
48
+ return readdirSync(routes)
49
+ .filter((f) => !f.startsWith('.'))
50
+ .sort()
51
+ .flatMap((project) => {
52
+ let text;
53
+ try {
54
+ text = readFileSync(join(routes, project), 'utf8');
55
+ } catch (e) {
56
+ if (e.code === 'ENOENT' || e.code === 'EISDIR') return [];
57
+ throw e;
58
+ }
59
+ const m = text.trim().match(ROUTE_RE);
60
+ return m ? [{ project, domain: m[1], bind: m[2], globe: m[3] }] : [];
61
+ });
62
+ }
63
+
64
+ // Written aside and renamed, so nginx never reads half a file. The file aside is a dot file, which
65
+ // readRoutes skips, so a write that stops half way leaves no second route.
66
+ function put(file, text) {
67
+ const aside = join(dirname(file), `.${basename(file)}.tmp.${process.pid}`);
68
+ writeFileSync(aside, text);
69
+ renameSync(aside, file);
70
+ }
71
+
72
+ /** Route `domain` and every name under it to `bind`, for the compose project `project` (the snowglobe `globe`). */
73
+ export function writeRoute(dir, { project, domain, bind, globe }) {
74
+ mkdirSync(join(dir, 'routes'), { recursive: true });
75
+ put(join(dir, 'routes', project), `${domain} ${bind} ${globe}\n`);
76
+ }
77
+
78
+ /** Stop routing to the compose project `project`. Returns whether it had a route. */
79
+ export function dropRoute(dir, project) {
80
+ const file = join(dir, 'routes', project);
81
+ if (!existsSync(file)) return false;
82
+ unlinkSync(file);
83
+ return true;
84
+ }
85
+
86
+ /**
87
+ * Write `route` ({ project, domain, bind, globe }) in `dir`, unless another project's route at the
88
+ * same domain is up (`isUp(project)`): that route is returned and nothing is written. Another's
89
+ * route there whose project is not up was left by a snowglobe stopped without snowglobe down; it is
90
+ * dropped, since nginx refuses the whole config with two routes at one domain. Returns null once
91
+ * written. Run it under the router's lock, as syncRouter's `change`.
92
+ */
93
+ export function claimDomain(dir, route, isUp, log = () => {}) {
94
+ const others = readRoutes(dir).filter((r) => r.project !== route.project && r.domain === route.domain);
95
+ const live = others.find((r) => isUp(r.project));
96
+ if (live) return live;
97
+ for (const r of others) {
98
+ dropRoute(dir, r.project);
99
+ log(`Dropped the route ${r.globe} (compose project ${r.project}) left at ${r.domain}: it is not up`);
100
+ }
101
+ writeRoute(dir, route);
102
+ return null;
103
+ }
104
+
105
+ /**
106
+ * The router's nginx.conf for `routes`: a domain and every name under it (nginx's ".domain" form)
107
+ * to that snowglobe's 443, the longest domain first (nginx prefers it whatever the order).
108
+ */
109
+ export function routerConfig(routes) {
110
+ const map = [...routes]
111
+ .sort((a, b) => b.domain.length - a.domain.length || a.domain.localeCompare(b.domain))
112
+ .map((r) => ` .${r.domain} ${r.bind}:443; # ${r.project}`);
113
+ return [
114
+ '# The snowglobe name router (lib/router.mjs). Written by snowglobe up and down from routes/; edits are lost.',
115
+ 'worker_processes 1;',
116
+ 'events { worker_connections 1024; }',
117
+ 'stream {',
118
+ ' map $ssl_preread_server_name $snowglobe {',
119
+ ' hostnames;',
120
+ ...map,
121
+ // A name under no routed domain: nothing listens there, so the connection is closed.
122
+ ' default unix:/run/snowglobe-router-none.sock;',
123
+ ' }',
124
+ ' server {',
125
+ ` listen ${ROUTER_ADDRESS}:443;`,
126
+ ' ssl_preread on;',
127
+ ' proxy_pass $snowglobe;',
128
+ ' }',
129
+ '}',
130
+ '',
131
+ ].join('\n');
132
+ }
133
+
134
+ const dockerCli = (env) => (args) => spawnSync('docker', args, { encoding: 'utf8', env });
135
+
136
+ /**
137
+ * The router container: { state: 'running' | 'stopped' | 'missing', dir }, dir being the folder
138
+ * it reads ('' when Docker does not say). `docker` runs a docker command (spawnSync's result).
139
+ */
140
+ export function routerState(docker = dockerCli(process.env)) {
141
+ const res = docker(['inspect', '-f', `{{.State.Running}}|{{range .Mounts}}{{if eq .Destination "${MOUNT}"}}{{.Source}}{{end}}{{end}}`, ROUTER_CONTAINER]);
142
+ if (res.error || res.status !== 0) return { state: 'missing', dir: '' };
143
+ const [running = '', dir = ''] = (res.stdout ?? '').trim().split('|');
144
+ return { state: running === 'true' ? 'running' : running === 'false' ? 'stopped' : 'missing', dir };
145
+ }
146
+
147
+ // The same folder, though the two may be spelled differently (a HOME reached through a link).
148
+ // Docker reports a mount's folder as it was given; one this machine cannot see is compared as text.
149
+ function sameFolder(a, b) {
150
+ try {
151
+ return realpathSync(a) === realpathSync(b);
152
+ } catch {
153
+ return a === b;
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Bring the router in line with the routes in `dir`: write nginx.conf, then reload a running
159
+ * router, start a stopped one, or make it; with no routes left, remove it. A router that reads
160
+ * another folder (started under another HOME) is refused, since it would never see these routes.
161
+ * `change`, run first under the same lock, changes the routes (claimDomain, dropRoute); when it
162
+ * returns false nothing changed and the router is left as it is. `docker` runs a docker command
163
+ * (spawnSync's result); `wait` is how long a router just started gets before it is checked.
164
+ */
165
+ export async function syncRouter({ dir = routerDir(), env = process.env, docker = dockerCli(env), log = () => {}, wait = 1000, change = () => true } = {}) {
166
+ // Two ups at once would both make the router, or one would write a config without the other's
167
+ // route, or both take one domain; one at a time, under a lock in the router's folder.
168
+ return withLock(
169
+ join(dir, '.lock'),
170
+ async () => {
171
+ if ((await change()) !== false) await sync({ dir, docker, log, wait });
172
+ },
173
+ { doing: 'updating the name router' },
174
+ );
175
+ }
176
+
177
+ async function sync({ dir, docker, log, wait }) {
178
+ const routes = readRoutes(dir);
179
+ const now = routerState(docker);
180
+ if (now.state !== 'missing' && now.dir && !sameFolder(now.dir, dir))
181
+ throw new Error(`the name router on this machine reads ${now.dir}, not ${dir} (it was started under another HOME); bring snowglobes up under that one, or docker rm -f ${ROUTER_CONTAINER} once none of its snowglobes is up`);
182
+ const must = (args) => {
183
+ const res = docker(args);
184
+ if (res.error) throw res.error;
185
+ if (res.status !== 0) throw new Error(`docker ${args.slice(0, 2).join(' ')} for the name router failed: ${(res.stderr ?? '').trim()}`);
186
+ };
187
+ if (!routes.length) {
188
+ if (now.state !== 'missing') {
189
+ must(['rm', '-f', ROUTER_CONTAINER]);
190
+ log('Removed the name router: no snowglobe is routed');
191
+ }
192
+ return;
193
+ }
194
+ mkdirSync(dir, { recursive: true });
195
+ put(join(dir, 'nginx.conf'), routerConfig(routes));
196
+ // With -c the reload reads this config before it signals, and fails when nginx would refuse it.
197
+ // Without it, it reads the image's own config and says nothing, while the router keeps the old routes.
198
+ if (now.state === 'running') return must(['exec', ROUTER_CONTAINER, 'nginx', '-s', 'reload', '-c', `${MOUNT}/nginx.conf`]);
199
+ if (now.state === 'stopped') must(['start', ROUTER_CONTAINER]);
200
+ else
201
+ must([
202
+ 'run', '-d', '--name', ROUTER_CONTAINER, '--label', 'snowglobe.router=1', '--restart', 'unless-stopped',
203
+ '--network', 'host', '-v', `${dir}:${MOUNT}:ro`,
204
+ ROUTER_IMAGE, 'nginx', '-g', 'daemon off;', '-c', `${MOUNT}/nginx.conf`,
205
+ ]);
206
+ // nginx that cannot listen exits at once; give it a moment, then look.
207
+ await sleep(wait);
208
+ if (routerState(docker).state !== 'running')
209
+ throw new Error(
210
+ `the name router did not stay up (docker logs ${ROUTER_CONTAINER} says why). Something else may hold ${ROUTER_ADDRESS}:443, ` +
211
+ 'such as a snowglobe made before 0.5.0 that kept that address: snowglobe down it, and up it again to move it.',
212
+ );
213
+ log(`Started the name router on ${ROUTER_ADDRESS}:443`);
214
+ }
package/lib/up.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  // What snowglobe up decides without Docker: the app routes the file adds, whether the globe's
2
2
  // PDSes clash with an app's handle ending, the env names the file's accounts keep their logins
3
- // under. Pure: inputs in, text or lists out (lib/commands/up.mjs runs the globe).
3
+ // under, whether the relay restarts. Pure: inputs in, text or lists out (lib/commands/up.mjs runs
4
+ // the globe).
4
5
 
5
6
  const envName = (label) => label.toUpperCase().replace(/-/g, '_');
6
7
 
@@ -52,3 +53,15 @@ export function endingClash(pdsNames, apps) {
52
53
  }
53
54
  return null;
54
55
  }
56
+
57
+ /**
58
+ * The first of `dialed` (service name -> its containers' start times) with a container that started
59
+ * after the relay did (`relay`, its containers' start times), or null. The times are Docker's
60
+ * State.StartedAt. The relay dials each PDS through nginx, and one that was running when either
61
+ * went away can stop picking up what the PDS sends, so up restarts it.
62
+ */
63
+ export function startedAfterRelay(relay, dialed) {
64
+ const since = Math.max(...relay.map((t) => Date.parse(t)));
65
+ for (const [service, times] of Object.entries(dialed)) if (times.some((t) => Date.parse(t) > since)) return service;
66
+ return null;
67
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openmeet/snowglobe",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Standalone, testable ATProto development network for local development and CI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -37,8 +37,8 @@
37
37
  "node": ">=20"
38
38
  },
39
39
  "dependencies": {
40
- "@peculiar/asn1-schema": "2.11.0",
41
- "@peculiar/asn1-x509": "2.11.0",
40
+ "@peculiar/asn1-schema": "^2.11.0",
41
+ "@peculiar/asn1-x509": "^2.11.0",
42
42
  "@peculiar/x509": "2.2.0",
43
43
  "reflect-metadata": "0.2.2",
44
44
  "yaml": "^2.9.1"
@@ -7,15 +7,14 @@
7
7
  # The whole thing, three PDSes, a relay and a lexicon authority:
8
8
  # https://github.com/OpenMeet-Team/snowglobe/blob/main/docs/examples/atmo.yml
9
9
 
10
- globe: snowglobe
11
- domain: local.snowglobe.at # every name below is under it
10
+ globe: snowglobe # every name below is under snowglobe.local.snowglobe.at, the globe's own domain
12
11
 
13
12
  pds:
14
- - name: main # https://main.local.snowglobe.at
13
+ - name: main # https://main.snowglobe.local.snowglobe.at
15
14
  image: ghcr.io/bluesky-social/pds:0.4.5037 # a current release
16
- bare_handles: true # its handles are <account>.local.snowglobe.at
17
- # A second PDS, with handles <name>.other.local.snowglobe.at. With more than one PDS the
18
- # relay turns on, so Jetstream sees every PDS's writes.
15
+ bare_handles: true # its handles are <account>.snowglobe.local.snowglobe.at
16
+ # A second PDS, with handles <name>.other.snowglobe.local.snowglobe.at. The relay crawls it too, so
17
+ # Jetstream sees its writes.
19
18
  # - name: other
20
19
  # image: ghcr.io/bluesky-social/pds:0.4.5009
21
20
  #
@@ -28,16 +27,16 @@ pds:
28
27
  services: # these are the defaults, each true or false
29
28
  jetstream: true
30
29
  slingshot: true # answers a handle's DID and PDS inside the network; needs jetstream
31
- pdsls: true # https://pdsls.local.snowglobe.at, a browser for the network's records
30
+ pdsls: true # https://pdsls.snowglobe.local.snowglobe.at, a browser for the network's records
32
31
  tap: false # sends an app the events of one collection (true, or a map; see snowglobe.md above)
33
- # relay: auto # auto: on when there is more than one PDS
32
+ # relay: auto # auto: on with Jetstream, which reads it, or more than one PDS
34
33
 
35
34
  # Where every PDS resolves lexicons (NSIDs) from. Name a PDS that is in this file.
36
35
  # lexicons:
37
36
  # authority: alpha
38
37
  # publish: lexicons # a folder next to this file; up publishes every lexicon in it
39
38
 
40
- # A group host runs beside the PDSes, at https://gh.local.snowglobe.at.
39
+ # A group host runs beside the PDSes, at https://gh.snowglobe.local.snowglobe.at.
41
40
  # grouphosts:
42
41
  # - name: gh
43
42
  # kind: opensocial
@@ -47,7 +46,7 @@ accounts: # <name>@<pds>; up makes each one
47
46
  - alice@main
48
47
  - bob@main
49
48
 
50
- # Your app, reached at https://myapp.app.local.snowglobe.at, with settings from the snowglobe.
49
+ # Your app, reached at https://myapp.app.snowglobe.local.snowglobe.at, with settings from the snowglobe.
51
50
  # `snowglobe env` hands them over. The names:
52
51
  # https://github.com/OpenMeet-Team/snowglobe/blob/main/docs/your-app.md
53
52
  # apps:
@@ -22,7 +22,8 @@ services:
22
22
  retries: 10
23
23
 
24
24
  maildev:
25
- image: maildev/maildev:latest
25
+ # Pinned: 3.0.0 moved the health check to /api/healthz (stack/page/snowglobe.conf asks there).
26
+ image: maildev/maildev:3.0.0
26
27
  restart: unless-stopped
27
28
  ports:
28
29
  - "${SG_MAILDEV_WEB_PORT:-${DEVNET_MAILDEV_WEB_PORT:-1081}}:1080"
@@ -98,17 +98,21 @@ services:
98
98
  timeout: 3s
99
99
  retries: 10
100
100
 
101
- # ── Jetstream (JSON event stream from PDS firehose) ──────────────
101
+ # ── Jetstream (JSON event stream from the relay's firehose) ──────
102
+ # Built from stack/jetstream: the pinned Jetstream, plus busybox for the health check. It reads
103
+ # a relay, not a PDS: at start it asks the relay for the network's hosts, which a PDS refuses.
102
104
  jetstream:
103
- image: ghcr.io/bluesky-social/jetstream:sha-05c89f0
105
+ build: ./jetstream
106
+ image: snowglobe-jetstream
104
107
  restart: unless-stopped
105
- depends_on:
106
- pds:
107
- condition: service_healthy
108
108
  environment:
109
- JETSTREAM_WS_URL: ws://pds:3000/xrpc/com.atproto.sync.subscribeRepos
110
- JETSTREAM_DATA_DIR: /data
111
- JETSTREAM_LIVENESS_TTL: 86400s
109
+ JETSTREAM_RELAY_URL: http://relay:2470
110
+ JETSTREAM_PLC_URL: http://plc:2582
111
+ # A folder of its own: a volume from an older snowglobe holds the older Jetstream's files.
112
+ JETSTREAM_DATA_DIR: /data/v2
113
+ JETSTREAM_ADDR: ":6008"
114
+ # Metrics, at /metrics.
115
+ JETSTREAM_DEBUG_ADDR: ":6009"
112
116
  volumes:
113
117
  - jetstream-data:/data
114
118
  ports:
@@ -116,11 +120,15 @@ services:
116
120
  - "${SG_JETSTREAM_METRICS_PORT:-${DEVNET_JETSTREAM_METRICS_PORT:-6009}}:6009"
117
121
  networks:
118
122
  - atproto-devnet
123
+ # Healthy once it serves: its archive answers 503 until then, as its streams do. The debug
124
+ # port's /readyz says only that it is listening.
119
125
  healthcheck:
120
- test: ["CMD-SHELL", "bash -c 'echo > /dev/tcp/127.0.0.1/6008'"]
126
+ test: ["CMD", "/busybox", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:6008/xrpc/network.bsky.jetstream.listSegments?limit=1"]
121
127
  interval: 5s
122
128
  timeout: 3s
123
129
  retries: 10
130
+ # A new snowglobe's first start takes a minute or two (docs/how-it-works.md).
131
+ start_period: 120s
124
132
 
125
133
  # ── TAP (repo sync + backfill) ───────────────────────────────────
126
134
  tap: