@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/README.md +36 -38
- package/bin/snowglobe.mjs +1 -1
- package/lib/certs.mjs +12 -9
- package/lib/commands/account.mjs +5 -7
- package/lib/commands/down.mjs +11 -2
- package/lib/commands/ls.mjs +18 -4
- package/lib/commands/run.mjs +17 -46
- package/lib/commands/up.mjs +57 -33
- package/lib/config.mjs +25 -10
- package/lib/env.mjs +39 -27
- package/lib/render.mjs +13 -9
- package/lib/router.mjs +214 -0
- package/lib/up.mjs +14 -1
- package/package.json +3 -3
- package/snowglobe.default.yml +9 -10
- package/stack/docker-compose.test.yml +2 -1
- package/stack/docker-compose.yml +17 -9
- package/stack/https/app-names.cjs +6 -5
- package/stack/jetstream/Dockerfile +12 -0
- package/stack/page/snowglobe.conf +1 -1
- package/lib/hosts.mjs +0 -36
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
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
|
|
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
|
-
|
|
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 ${
|
|
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
|
|
235
|
-
//
|
|
236
|
-
|
|
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 {
|
|
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 {
|
|
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;
|
|
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 ||
|
|
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.
|
|
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 })
|
|
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 (
|
|
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
|
-
|
|
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
|
|
329
|
-
if (Date.now() > until) throw new Error(`another snowglobe 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 =
|
|
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(
|
|
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
|
|
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
|
-
//
|
|
158
|
-
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
|
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
|
|
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
|
+
"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"
|
package/snowglobe.default.yml
CHANGED
|
@@ -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.
|
|
18
|
-
#
|
|
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
|
|
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
|
-
|
|
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"
|
package/stack/docker-compose.yml
CHANGED
|
@@ -98,17 +98,21 @@ services:
|
|
|
98
98
|
timeout: 3s
|
|
99
99
|
retries: 10
|
|
100
100
|
|
|
101
|
-
# ── Jetstream (JSON event stream from
|
|
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
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
|
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:
|