@openmeet/snowglobe 0.4.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 +34 -36
- 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 +37 -14
- package/lib/config.mjs +20 -7
- package/lib/env.mjs +39 -27
- package/lib/render.mjs +4 -3
- package/lib/router.mjs +214 -0
- package/package.json +1 -1
- package/snowglobe.default.yml +7 -8
- package/stack/docker-compose.test.yml +2 -1
- package/stack/https/app-names.cjs +6 -5
- package/stack/page/snowglobe.conf +1 -1
- package/lib/hosts.mjs +0 -36
package/README.md
CHANGED
|
@@ -19,9 +19,9 @@ One file, `snowglobe.yml`, says what a snowglobe runs. [`snowglobe.default.yml`]
|
|
|
19
19
|
is the one to start from. It gives you:
|
|
20
20
|
|
|
21
21
|
- one current PDS (personal data server, where an account's records live and where it signs in), at
|
|
22
|
-
`https://main.local.snowglobe.at`, with two test accounts, `alice` and `bob`;
|
|
23
|
-
- a PLC directory, the phone book that says which PDS holds each `did:plc` account;
|
|
24
|
-
|
|
22
|
+
`https://main.snowglobe.local.snowglobe.at`, with two test accounts, `alice` and `bob`;
|
|
23
|
+
- a PLC directory, the phone book that says which PDS holds each `did:plc` account; your app is told
|
|
24
|
+
its address;
|
|
25
25
|
- Jetstream, a simple stream of every write on the network, for apps that follow it;
|
|
26
26
|
- Slingshot, which looks up a handle's DID and PDS inside the network;
|
|
27
27
|
- PDSls, to browse the network's records;
|
|
@@ -39,36 +39,32 @@ The file also holds commented examples for adding more, up to a full setup:
|
|
|
39
39
|
[`docs/examples/atmo.yml`](docs/examples/atmo.yml) is a larger file, with three PDSes, a relay and a
|
|
40
40
|
lexicon authority, that [Example: atmo](docs/examples/atmo.md) builds on.
|
|
41
41
|
|
|
42
|
-
Every name is under
|
|
43
|
-
local CA that the snowglobe makes for itself. There is no AppView and no feed generator.
|
|
42
|
+
Every name is under the snowglobe's own domain, `<globe>.local.snowglobe.at`, served by nginx with a
|
|
43
|
+
certificate from a local CA that the snowglobe makes for itself. There is no AppView and no feed generator.
|
|
44
44
|
[The snowglobe](docs/snowglobe.md) shows the whole file and what each part does.
|
|
45
45
|
|
|
46
46
|
## What you need
|
|
47
47
|
|
|
48
|
-
Every service in a snowglobe has its own https name under
|
|
49
|
-
`alice.local.snowglobe.at
|
|
50
|
-
your computer has to send the names to the snowglobe, and trust its
|
|
48
|
+
Every service in a snowglobe has its own https name under the snowglobe's domain, such as
|
|
49
|
+
`alice.snowglobe.local.snowglobe.at` for the default file. Handles, sign-in (OAuth) and DID documents
|
|
50
|
+
all carry those names, so your computer has to send the names to the snowglobe, and trust its
|
|
51
|
+
certificate. Here is what to do.
|
|
51
52
|
|
|
52
|
-
**Before you start it:**
|
|
53
|
-
|
|
54
|
-
1. Install Docker with Compose, and Node 20 or newer. Use Linux, or Windows with WSL 2.
|
|
55
|
-
2. To run a command against the snowglobe from your machine (`snowglobe run`: your own app, its
|
|
56
|
-
tests, or the `curl` example below), also install
|
|
57
|
-
[bubblewrap](https://github.com/containers/bubblewrap): `sudo apt install bubblewrap` on Debian or
|
|
58
|
-
Ubuntu. You do not need it to bring a snowglobe up or to look at one in a browser.
|
|
53
|
+
**Before you start it:** install Docker with Compose, and Node 20 or newer. Use Linux, or Windows with
|
|
54
|
+
WSL 2.
|
|
59
55
|
|
|
60
56
|
**After it is up, to open it in a browser or reach it from an app:**
|
|
61
57
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
58
|
+
1. The names reach the snowglobe on their own. Public DNS answers every name under
|
|
59
|
+
`local.snowglobe.at` with `127.0.0.1`, where `snowglobe up` starts a name router that passes each
|
|
60
|
+
connection to the snowglobe the name is under, so every snowglobe on the machine answers at once.
|
|
61
|
+
- If the browser says "server not found", your router is dropping that answer. See
|
|
66
62
|
[When the browser cannot open it](docs/browser.md#when-the-browser-cannot-open-it).
|
|
67
|
-
- Docker in a dev container, or on another machine
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
63
|
+
- With Docker in a dev container, or on another machine, forward its port 443 to your computer
|
|
64
|
+
([From a browser on your own computer](docs/browser.md)).
|
|
65
|
+
2. Trust the snowglobe's certificate authority (CA). Run `snowglobe trust` on the machine that runs
|
|
66
|
+
Docker. For another machine, see [From a browser on your own computer](docs/browser.md). A
|
|
67
|
+
command started with `snowglobe run` trusts it without this.
|
|
72
68
|
|
|
73
69
|
**Why not just use ports?** The PDSes are not published on ports at all: only nginx (443), the proxy,
|
|
74
70
|
the PLC, Jetstream and MailDev are. A PDS's address is its name, and that name is what a DID document, a
|
|
@@ -93,25 +89,25 @@ To keep the command with one project instead, run `npm install --save-dev @openm
|
|
|
93
89
|
`snowglobe.yml` (copy it from `node_modules/@openmeet/snowglobe/snowglobe.default.yml`). Either
|
|
94
90
|
command also fits in a `package.json` script.
|
|
95
91
|
|
|
96
|
-
Now try it.
|
|
97
|
-
|
|
92
|
+
Now try it. The snowglobe's names work for any program on the machine, and `--cacert` gives curl the
|
|
93
|
+
snowglobe's certificate authority (CA), so it trusts the snowglobe's https:
|
|
98
94
|
|
|
99
95
|
```bash
|
|
100
96
|
# Sign up a new account, then look up alice's account through Slingshot
|
|
101
97
|
snowglobe account carol CAROL # CAROL: the prefix of her login variables, such as CAROL_PASSWORD
|
|
102
|
-
|
|
103
|
-
'https://slingshot.local.snowglobe.at/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=alice.local.snowglobe.at'
|
|
98
|
+
curl -s --cacert ~/.local/share/snowglobe/ca/ca.crt \
|
|
99
|
+
'https://slingshot.snowglobe.local.snowglobe.at/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=alice.snowglobe.local.snowglobe.at'
|
|
104
100
|
```
|
|
105
101
|
|
|
106
102
|
To look around in a browser, you need two things: the browser has to trust the snowglobe's
|
|
107
103
|
certificate, and it has to be able to find the snowglobe. If Docker runs on the same computer as your
|
|
108
|
-
browser, run `snowglobe trust` and open `https://local.snowglobe.at/`, the network's page,
|
|
104
|
+
browser, run `snowglobe trust` and open `https://snowglobe.local.snowglobe.at/`, the network's page,
|
|
109
105
|
which lists every service, account and write. If Docker runs in a dev container or on another
|
|
110
|
-
machine,
|
|
111
|
-
|
|
106
|
+
machine, also forward its port 443 to your computer;
|
|
107
|
+
[From a browser on your own computer](docs/browser.md) walks through both.
|
|
112
108
|
|
|
113
109
|
The default snowglobe comes with one app to look at, [PDSls](docs/snowglobe.md#pdsls-browsing-the-networks-records),
|
|
114
|
-
the record browser. `https://pdsls.local.snowglobe.at/at://alice.local.snowglobe.at` shows alice's repo,
|
|
110
|
+
the record browser. `https://pdsls.snowglobe.local.snowglobe.at/at://alice.snowglobe.local.snowglobe.at` shows alice's repo,
|
|
115
111
|
once your browser can reach the snowglobe as above.
|
|
116
112
|
|
|
117
113
|
Stop the snowglobe with `snowglobe down`. Add `-v` to delete its data too, every account
|
|
@@ -123,10 +119,10 @@ To run your own app inside of a snowglobe, see [Your app in a snowglobe](docs/yo
|
|
|
123
119
|
|
|
124
120
|
## Platforms
|
|
125
121
|
|
|
126
|
-
Snowglobe runs on Linux, and on Windows under WSL 2. Both are tested. macOS is not supported
|
|
122
|
+
Snowglobe runs on Linux, and on Windows under WSL 2. Both are tested. macOS is not supported yet.
|
|
127
123
|
If you try it on a Mac, please
|
|
128
124
|
[open an issue](https://github.com/OpenMeet-Team/snowglobe/issues) with what broke, or that it
|
|
129
|
-
worked
|
|
125
|
+
worked.
|
|
130
126
|
|
|
131
127
|
## Documentation
|
|
132
128
|
|
|
@@ -134,11 +130,13 @@ worked, and we'd love to experiment with you.
|
|
|
134
130
|
down, and what it writes.
|
|
135
131
|
- [Your app in a snowglobe](docs/your-app.md): `snowglobe run`, and the tools that make an app's
|
|
136
132
|
accounts, invite codes, lexicons and routes.
|
|
137
|
-
- [From a browser on your own computer](docs/browser.md): the
|
|
138
|
-
CA.
|
|
133
|
+
- [From a browser on your own computer](docs/browser.md): the name router, Docker on another
|
|
134
|
+
machine, and trusting the CA.
|
|
139
135
|
- [How it works](docs/how-it-works.md): what runs and how it is wired, Slingshot, and the relay build.
|
|
140
136
|
- [Configuration](docs/configuration.md): the settings.
|
|
141
137
|
- [Testing Snowglobe](docs/testing.md): the test suite and the smoke run.
|
|
138
|
+
- [Example: Statusphere](docs/examples/statusphere.md): the atproto example app on a snowglobe, with
|
|
139
|
+
two small changes. CI runs it end to end.
|
|
142
140
|
- [Example: atmo](docs/examples/atmo.md): an events app on a local snowglobe.
|
|
143
141
|
- The older plain-http stack (`npm run up`) is not in this version. Its code and README are in the
|
|
144
142
|
[v0.2.0 tag](https://github.com/OpenMeet-Team/snowglobe/tree/v0.2.0) (v0.1.0 has the first version of it).
|
package/bin/snowglobe.mjs
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// app routes https://<name>.app.<domain> to a port on this machine
|
|
14
14
|
// up brings the globe up, as the compose project named for its file (or <name>, -f <file>)
|
|
15
15
|
// down stops it (the same ways)
|
|
16
|
-
// run runs a command with the app's settings
|
|
16
|
+
// run runs a command with the app's settings and the snowglobe's CA
|
|
17
17
|
//
|
|
18
18
|
// A snowglobe is the snowglobe.yml in the folder you are in or the nearest one above (else
|
|
19
19
|
// SG_FILE, else the default). Its state is kept per user, not in a checkout: ~/.local/share/
|
package/lib/certs.mjs
CHANGED
|
@@ -9,14 +9,15 @@
|
|
|
9
9
|
// Delete the CA's ca.* to start over, and then trust the new ca.crt again everywhere the old one
|
|
10
10
|
// was trusted.
|
|
11
11
|
//
|
|
12
|
-
// The CA can only vouch for servers under
|
|
12
|
+
// The CA can only vouch for servers under local.snowglobe.at (the zone every snowglobe's own
|
|
13
|
+
// domain is under, <globe>.local.snowglobe.at), a domain outside it that it was made for,
|
|
13
14
|
// and plc.directory (name constraints, serverAuth only, no IP addresses), so
|
|
14
15
|
// trusting it on a host cannot put any other site at risk, even if ca.key leaks. A name outside
|
|
15
|
-
// those is refused before the leaf is touched; widening the list, or a snowglobe on
|
|
16
|
-
//
|
|
16
|
+
// those is refused before the leaf is touched; widening the list, or a snowglobe on a domain
|
|
17
|
+
// outside the zone made after the CA, means a new CA, trusted again.
|
|
17
18
|
//
|
|
18
|
-
// A TLS wildcard covers one label, so *.local.snowglobe.at does not cover a handle on the PDS
|
|
19
|
-
// named regular (<name>.regular.local.snowglobe.at); each handle domain has its own.
|
|
19
|
+
// A TLS wildcard covers one label, so *.mine.local.snowglobe.at does not cover a handle on the PDS
|
|
20
|
+
// named regular (<name>.regular.mine.local.snowglobe.at); each handle domain has its own.
|
|
20
21
|
|
|
21
22
|
import 'reflect-metadata';
|
|
22
23
|
import { AsnConvert } from '@peculiar/asn1-schema';
|
|
@@ -25,7 +26,7 @@ import * as x509 from '@peculiar/x509';
|
|
|
25
26
|
import { X509Certificate as NodeCertificate, createPrivateKey, randomBytes, webcrypto } from 'node:crypto';
|
|
26
27
|
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
27
28
|
import { join } from 'node:path';
|
|
28
|
-
import {
|
|
29
|
+
import { LOCAL_ZONE } from './config.mjs';
|
|
29
30
|
|
|
30
31
|
x509.cryptoProvider.set(webcrypto);
|
|
31
32
|
|
|
@@ -104,9 +105,11 @@ async function makeCa(domain, now) {
|
|
|
104
105
|
const name = `snowglobe local CA ${now.toISOString().slice(0, 10)} ${randomBytes(3).toString('hex')}`;
|
|
105
106
|
const subtree = (gn) => new GeneralSubtree({ base: new GeneralName(gn) });
|
|
106
107
|
const constraints = new NameConstraints({
|
|
107
|
-
// One CA serves every snowglobe, so the
|
|
108
|
-
// snowglobe that makes it is on.
|
|
109
|
-
permittedSubtrees: new GeneralSubtrees(
|
|
108
|
+
// One CA serves every snowglobe, so the local zone is always in, whatever domain the
|
|
109
|
+
// snowglobe that makes it is on; a domain inside the zone needs no subtree of its own.
|
|
110
|
+
permittedSubtrees: new GeneralSubtrees(
|
|
111
|
+
[...new Set([LOCAL_ZONE, ...(within(domain, [LOCAL_ZONE]) ? [] : [domain]), ...FIXED])].map((d) => subtree({ dNSName: d })),
|
|
112
|
+
),
|
|
110
113
|
excludedSubtrees: new GeneralSubtrees([subtree({ iPAddress: '0.0.0.0/0' }), subtree({ iPAddress: '::/0' })]),
|
|
111
114
|
});
|
|
112
115
|
const cert = await x509.X509CertificateGenerator.createSelfSigned({
|
package/lib/commands/account.mjs
CHANGED
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
//
|
|
3
3
|
// account creates or finds <name> on <pds> (the PDS with bare handles, else the seed PDS):
|
|
4
4
|
// <name>.<domain> there, <name>.<pds>.<domain> on the others. Its login goes into ACCOUNTS_FILE
|
|
5
|
-
// (accounts.env in the snowglobe's state folder by default, mode 600) as <PREFIX>_HANDLE, _DID, _PASSWORD and _PDS,
|
|
6
|
-
//
|
|
7
|
-
//
|
|
5
|
+
// (accounts.env in the snowglobe's state folder by default, mode 600) as <PREFIX>_HANDLE, _DID, _PASSWORD and _PDS, and
|
|
6
|
+
// its DID alone to stdout. The handle resolves at once: it is under the snowglobe's domain, which
|
|
7
|
+
// the name router serves. A rerun finds it. It reads the rendered globe, and prints no secret
|
|
8
8
|
// (lib/xrpc.mjs).
|
|
9
9
|
|
|
10
10
|
import { randomBytes } from 'node:crypto';
|
|
11
|
-
import { ROOT, UsageError,
|
|
11
|
+
import { ROOT, UsageError, envGet, envPut, globeFacts, loginsFile, paths } from '../env.mjs';
|
|
12
12
|
import { session } from '../state.mjs';
|
|
13
13
|
import { mintInvite, why, xrpc } from '../xrpc.mjs';
|
|
14
14
|
|
|
@@ -31,7 +31,7 @@ export async function run({ positionals: [name, prefix, pds] }) {
|
|
|
31
31
|
/**
|
|
32
32
|
* Create, or find, the account <name> on the globe's PDS `pds` (the one with bare handles, else
|
|
33
33
|
* the seed PDS, by default), and keep its login in `file` as <prefix>_HANDLE, _DID, _PASSWORD and
|
|
34
|
-
* _PDS.
|
|
34
|
+
* _PDS. Resolves to the DID. A rerun signs in
|
|
35
35
|
* with the kept password and finds the account; a PDS that takes invite codes is sent one
|
|
36
36
|
* (SG_INVITE_CODE, else a single-use code minted for this account), the others none.
|
|
37
37
|
*/
|
|
@@ -64,7 +64,6 @@ export async function account({ name, prefix, pds, file, root = ROOT, state, env
|
|
|
64
64
|
const res = await xrpc(pdsUrl, 'com.atproto.server.createSession', { body: { identifier: handle, password: kept }, ...opts });
|
|
65
65
|
if (res.ok && typeof res.json.did === 'string') {
|
|
66
66
|
if (envGet(`${prefix}_DID`, accounts) !== res.json.did) save(res.json.did, kept);
|
|
67
|
-
addName(handle, { root, state });
|
|
68
67
|
log(`Found ${handle} -> ${res.json.did}`);
|
|
69
68
|
return res.json.did;
|
|
70
69
|
}
|
|
@@ -79,7 +78,6 @@ export async function account({ name, prefix, pds, file, root = ROOT, state, env
|
|
|
79
78
|
const res = await xrpc(pdsUrl, 'com.atproto.server.createAccount', { body, ...opts });
|
|
80
79
|
if (!res.ok || typeof res.json.did !== 'string') throw new Error(`createAccount ${handle} on ${pdsUrl} failed: ${why(res)}`);
|
|
81
80
|
save(res.json.did, password);
|
|
82
|
-
addName(handle, { root, state });
|
|
83
81
|
log(`Created ${handle} on ${pdsUrl} -> ${res.json.did}`);
|
|
84
82
|
return res.json.did;
|
|
85
83
|
}
|
package/lib/commands/down.mjs
CHANGED
|
@@ -11,9 +11,14 @@
|
|
|
11
11
|
// alice and bob and rewrites accounts.env from scratch, dropping the logins the tools wrote there
|
|
12
12
|
// (their accounts are gone too), and the lexicon authority is made again. The fixed secrets an
|
|
13
13
|
// older snowglobe kept for its data (secrets.env) are dropped with it, so up makes random ones. The CA, the leaf and
|
|
14
|
-
// the app routes stay either way. The project is guarded as up guards it.
|
|
14
|
+
// the app routes stay either way. The project is guarded as up guards it. Once the project is
|
|
15
|
+
// down, the name router stops routing to it, and is removed when no snowglobe is left to route
|
|
16
|
+
// (lib/router.mjs).
|
|
15
17
|
|
|
18
|
+
import { existsSync } from 'node:fs';
|
|
19
|
+
import { join } from 'node:path';
|
|
16
20
|
import { ROOT, dc, dropLegacySecrets, guardProject } from '../env.mjs';
|
|
21
|
+
import { dropRoute, routerDir, syncRouter } from '../router.mjs';
|
|
17
22
|
import { byName, pointedAt, session } from '../state.mjs';
|
|
18
23
|
|
|
19
24
|
export const command = {
|
|
@@ -27,7 +32,7 @@ export function run({ values, positionals: [word] }) {
|
|
|
27
32
|
return down({ volumes: values.volumes, ...pointedAt({ file: values.file, word }) });
|
|
28
33
|
}
|
|
29
34
|
|
|
30
|
-
export function down({ volumes = false, file, name, root = ROOT, env = process.env } = {}) {
|
|
35
|
+
export async function down({ volumes = false, file, name, root = ROOT, env = process.env, log = (m) => console.error(m) } = {}) {
|
|
31
36
|
// The shared devnet is refused before anything is looked up, even by a file that is not there.
|
|
32
37
|
if (env.SG_PROJECT) guardProject(env);
|
|
33
38
|
const found = name ? byName(name, { env }) : session({ file, env });
|
|
@@ -36,5 +41,9 @@ export function down({ volumes = false, file, name, root = ROOT, env = process.e
|
|
|
36
41
|
if (res.error) throw res.error;
|
|
37
42
|
// The data the fixed secrets were kept for is gone only once compose says it removed it.
|
|
38
43
|
if (volumes && res.status === 0) dropLegacySecrets({ root, state: found.state });
|
|
44
|
+
// Only a snowglobe the router routes to touches it. The route is dropped under the router's
|
|
45
|
+
// lock; when another down got there first, the router is already in line.
|
|
46
|
+
const rdir = routerDir(env);
|
|
47
|
+
if (res.status === 0 && existsSync(join(rdir, 'routes', project))) await syncRouter({ dir: rdir, env, log, change: () => dropRoute(rdir, project) });
|
|
39
48
|
return res.status ?? 1;
|
|
40
49
|
}
|
package/lib/commands/ls.mjs
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
// bin/snowglobe.mjs ls
|
|
2
2
|
//
|
|
3
3
|
// ls lists the snowglobes on this machine: every folder in the state root (lib/env.mjs stateRoot;
|
|
4
|
-
// ca/, the one CA,
|
|
5
|
-
//
|
|
4
|
+
// ca/, the one CA, and dot folders such as the name router's .router are not ones) with its name,
|
|
5
|
+
// the domain it was rendered for, whether its compose project is running, and the file that owns
|
|
6
|
+
// it. Take the name to `up`, `down`, or a file to -f.
|
|
6
7
|
// "up" is a project with a container running (the one-shot init and relay-init have exited by
|
|
7
8
|
// then), "down" is none, or no project yet; "unknown" is what it says when Docker did not answer.
|
|
9
|
+
// When the name router routes any snowglobe, a last line says whether it is running and to which.
|
|
8
10
|
// Reads only; nothing is started.
|
|
9
11
|
|
|
10
12
|
import { spawnSync } from 'node:child_process';
|
|
11
13
|
import { existsSync, readdirSync } from 'node:fs';
|
|
12
14
|
import { join } from 'node:path';
|
|
13
|
-
import { ROOT, envGet, paths, stateRoot } from '../env.mjs';
|
|
15
|
+
import { ROOT, ROUTER_ADDRESS, envGet, paths, stateRoot } from '../env.mjs';
|
|
16
|
+
import { readRoutes, routerDir, routerState } from '../router.mjs';
|
|
14
17
|
import { ownerOf } from '../state.mjs';
|
|
15
18
|
|
|
16
19
|
export const command = {
|
|
@@ -44,7 +47,7 @@ export function list({ root = ROOT, env = process.env, statuses = projectStatuse
|
|
|
44
47
|
const base = stateRoot(env);
|
|
45
48
|
if (!existsSync(base)) return [];
|
|
46
49
|
return readdirSync(base, { withFileTypes: true })
|
|
47
|
-
.filter((e) => e.isDirectory() && e.name !== 'ca')
|
|
50
|
+
.filter((e) => e.isDirectory() && e.name !== 'ca' && !e.name.startsWith('.'))
|
|
48
51
|
.map((e) => {
|
|
49
52
|
const state = join(base, e.name);
|
|
50
53
|
const owner = ownerOf(state);
|
|
@@ -58,15 +61,26 @@ export function list({ root = ROOT, env = process.env, statuses = projectStatuse
|
|
|
58
61
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
59
62
|
}
|
|
60
63
|
|
|
64
|
+
// The last line: whether the name router runs and what it routes, when it routes anything. Shown
|
|
65
|
+
// with no snowglobe listed too, since a route can outlive its state folder.
|
|
66
|
+
function routerLine() {
|
|
67
|
+
const routes = readRoutes(routerDir());
|
|
68
|
+
if (!routes.length) return;
|
|
69
|
+
const word = { running: 'running', stopped: 'stopped', missing: 'not running' }[routerState().state];
|
|
70
|
+
console.log(`\nName router on ${ROUTER_ADDRESS}:443: ${word}, routing ${routes.map((r) => `${r.domain} to ${r.globe}`).join(', ')}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
61
73
|
export function run() {
|
|
62
74
|
const rows = list();
|
|
63
75
|
if (!rows.length) {
|
|
64
76
|
console.log(`No snowglobes in ${stateRoot()}. snowglobe up in a folder with a snowglobe.yml makes one.`);
|
|
77
|
+
routerLine();
|
|
65
78
|
return 0;
|
|
66
79
|
}
|
|
67
80
|
const head = { name: 'NAME', domain: 'DOMAIN', state: 'STATE', file: 'FILE' };
|
|
68
81
|
const cols = Object.keys(head);
|
|
69
82
|
const width = Object.fromEntries(cols.map((c) => [c, Math.max(head[c].length, ...rows.map((r) => r[c].length))]));
|
|
70
83
|
for (const r of [head, ...rows]) console.log(cols.map((c, i) => (i === cols.length - 1 ? r[c] : r[c].padEnd(width[c]))).join(' '));
|
|
84
|
+
routerLine();
|
|
71
85
|
return 0;
|
|
72
86
|
}
|
package/lib/commands/run.mjs
CHANGED
|
@@ -1,14 +1,10 @@
|
|
|
1
1
|
// bin/snowglobe.mjs run [--app <name>] [--] <cmd> [args...]
|
|
2
2
|
//
|
|
3
|
-
// run runs a command with the snowglobe
|
|
3
|
+
// run runs a command with the snowglobe's settings:
|
|
4
4
|
// - it is handed the app's settings, the apps[].env of the snowglobe.yml (lib/commands/env.mjs;
|
|
5
5
|
// the same as snowglobe env, secrets included, none printed). They win over a value already in
|
|
6
6
|
// the environment, and one line names the ones they replaced. --app picks the app when the
|
|
7
7
|
// file lists several;
|
|
8
|
-
// - the globe's names (its page, each PDS, its services and plc.directory, from the rendered
|
|
9
|
-
// globe), every handle in https/names (account and up add them) and every app in
|
|
10
|
-
// https/apps, as <name>.app.<domain>, resolve to SG_BIND, where nginx answers on
|
|
11
|
-
// 443 (lib/hosts.mjs). HTTPS_RUN_EXTRA_NAMES adds names (space-separated);
|
|
12
8
|
// - Node trusts the globe's CA (NODE_EXTRA_CA_CERTS=<state root>/ca/ca.crt), on top of its own
|
|
13
9
|
// roots. Miniflare passes the same file on to workerd.
|
|
14
10
|
//
|
|
@@ -17,20 +13,18 @@
|
|
|
17
13
|
// e.g. bin/snowglobe.mjs run node scripts/https-probe.mjs
|
|
18
14
|
// (cd ../myapp && ../snowglobe/bin/snowglobe.mjs run npm run dev)
|
|
19
15
|
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
16
|
+
// Nothing maps names. Each snowglobe has a domain of its own under local.snowglobe.at, whose
|
|
17
|
+
// public DNS answers every name with 127.0.0.1, where the name router passes each connection to
|
|
18
|
+
// the snowglobe the name is under (lib/router.mjs). So every name resolves as it does anywhere on
|
|
19
|
+
// the machine, a handle made after the command started included. plc.directory is the real one:
|
|
20
|
+
// an app takes the snowglobe's PLC directory as a setting (${services.plc.url}), as it must on a
|
|
21
|
+
// hosted globe. A domain of your own, outside local.snowglobe.at, has to resolve to 127.0.0.1 the
|
|
22
|
+
// same way.
|
|
26
23
|
|
|
27
24
|
import { spawn } from 'node:child_process';
|
|
28
|
-
import {
|
|
25
|
+
import { existsSync } from 'node:fs';
|
|
29
26
|
import { constants as os } from 'node:os';
|
|
30
|
-
import {
|
|
31
|
-
import { readApps } from '../apps.mjs';
|
|
32
|
-
import { ROOT, paths, resolveBind, resolveDomain } from '../env.mjs';
|
|
33
|
-
import { hostsFile, runNames } from '../hosts.mjs';
|
|
27
|
+
import { ROOT, paths, resolveDomain } from '../env.mjs';
|
|
34
28
|
import { session } from '../state.mjs';
|
|
35
29
|
import { appSettings } from './env.mjs';
|
|
36
30
|
|
|
@@ -48,48 +42,25 @@ export const command = {
|
|
|
48
42
|
},
|
|
49
43
|
};
|
|
50
44
|
|
|
51
|
-
// The first `name` on PATH that can be run, or null.
|
|
52
|
-
function onPath(name, env) {
|
|
53
|
-
for (const dir of (env.PATH ?? '').split(delimiter).filter(Boolean)) {
|
|
54
|
-
try {
|
|
55
|
-
accessSync(join(dir, name), constants.X_OK);
|
|
56
|
-
return join(dir, name);
|
|
57
|
-
} catch {}
|
|
58
|
-
}
|
|
59
|
-
return null;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
45
|
export const run = ({ cmd, app }) => runUnder(cmd, { settings: appSettings({ app, secrets: true }).vars });
|
|
63
46
|
|
|
64
47
|
/**
|
|
65
|
-
* Run `cmd` (a word list)
|
|
66
|
-
*
|
|
67
|
-
*
|
|
48
|
+
* Run `cmd` (a word list) with `settings` ([{ name, value }], from appSettings) and the globe's CA
|
|
49
|
+
* in its environment; resolves to its exit code. A setting replaces a value of the same name
|
|
50
|
+
* already in `env`, and `log` is told which.
|
|
68
51
|
*/
|
|
69
52
|
export function runUnder(cmd, { root = ROOT, env = process.env, state = session({ env }).state, settings = [], log = (m) => console.error(m) } = {}) {
|
|
70
|
-
if (process.platform !== 'linux')
|
|
71
|
-
throw new Error('run needs Linux (bubblewrap) for now; on this system eval "$(snowglobe env)" hands the app the same settings');
|
|
72
53
|
const p = paths(root, state);
|
|
73
54
|
if (!existsSync(p.ca)) throw new Error(`no ${p.ca}; run snowglobe up (or certs) first`);
|
|
74
|
-
if (!existsSync(p.
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
// Refuses a SG_DOMAIN the render is not for, and a SG_BIND that is not loopback.
|
|
78
|
-
const { domain } = resolveDomain({ root, state, env });
|
|
79
|
-
const bind = resolveBind({ root, state, env });
|
|
80
|
-
|
|
81
|
-
const read = (f) => (existsSync(f) ? readFileSync(f, 'utf8') : '');
|
|
82
|
-
const names = runNames({ leafNames: read(p.leafNames), handles: read(p.names), apps: readApps(p), domain, extra: env.HTTPS_RUN_EXTRA_NAMES });
|
|
83
|
-
// Written aside and renamed, so a run already holding the old file is undisturbed.
|
|
84
|
-
const hosts = join(p.https, 'hosts');
|
|
85
|
-
writeFileSync(`${hosts}.tmp.${process.pid}`, hostsFile(read('/etc/hosts'), names, bind), { mode: 0o644 });
|
|
86
|
-
renameSync(`${hosts}.tmp.${process.pid}`, hosts);
|
|
55
|
+
if (!existsSync(p.globeEnv)) throw new Error(`no ${p.globeEnv}; run snowglobe up first`);
|
|
56
|
+
// Refuses a SG_DOMAIN the render is not for.
|
|
57
|
+
resolveDomain({ root, state, env });
|
|
87
58
|
|
|
88
59
|
// Never the values: a setting may be a login.
|
|
89
60
|
const replaced = settings.filter((x) => !x.auto && env[x.name] !== undefined && env[x.name] !== x.value).map((x) => x.name);
|
|
90
61
|
if (replaced.length) log(`snowglobe run: the snowglobe's settings replaced ${replaced.join(', ')} from the environment`);
|
|
91
62
|
|
|
92
|
-
const child = spawn(
|
|
63
|
+
const child = spawn(cmd[0], cmd.slice(1), {
|
|
93
64
|
stdio: 'inherit',
|
|
94
65
|
env: { ...env, NODE_EXTRA_CA_CERTS: p.ca, ...Object.fromEntries(settings.map((x) => [x.name, x.value])) },
|
|
95
66
|
});
|
package/lib/commands/up.mjs
CHANGED
|
@@ -23,7 +23,9 @@
|
|
|
23
23
|
// group host built from a folder is built again (Docker's cache keeps that quick)
|
|
24
24
|
// 4. pass 1: up. The base init seeds alice, bob and SG_INVITE_CODE into
|
|
25
25
|
// data/accounts.env on the PDS with bare handles (else the first), as it does for the
|
|
26
|
-
// http stacks.
|
|
26
|
+
// http stacks. Then the name router (lib/router.mjs) sends the globe's domain and every name
|
|
27
|
+
// under it to the globe's own address, and is started if it is not running. A domain another
|
|
28
|
+
// running snowglobe is routed at is refused before anything starts.
|
|
27
29
|
// 5. the lexicon authority, lex-authority on the file's lexicons.authority PDS
|
|
28
30
|
// (LEX_AUTHORITY_* in data/accounts.env), then pass 2: up with
|
|
29
31
|
// SG_LEXICON_AUTHORITY_DID, which recreates the PDSes with it
|
|
@@ -48,8 +50,9 @@ import { dirname, join, resolve } from 'node:path';
|
|
|
48
50
|
import { setTimeout as sleep } from 'node:timers/promises';
|
|
49
51
|
import { appEnding, appEndingMessage, formatApps, readApps, renderRoutes, writeApps } from '../apps.mjs';
|
|
50
52
|
import { loadGlobe } from '../config.mjs';
|
|
51
|
-
import { ROOT,
|
|
53
|
+
import { ROOT, ROUTER_ADDRESS, chooseBind, dc, ensureSecrets, projectHasData, snowglobeRequest, envGet, guardProject, makePrivate, oldDataNotice, ownNginxUp, paths, renamedDefaultNotice, resolveBind, resolveDomain, routerRuns } from '../env.mjs';
|
|
52
54
|
import { prebuilt } from '../prebuilt.mjs';
|
|
55
|
+
import { claimDomain, readRoutes, routerDir, syncRouter } from '../router.mjs';
|
|
53
56
|
import { byName, claimState, pointedAt, session } from '../state.mjs';
|
|
54
57
|
import { accountPrefixes, endingClash, mergeRoutes, startedAfterRelay } from '../up.mjs';
|
|
55
58
|
import { account } from './account.mjs';
|
|
@@ -126,7 +129,23 @@ export async function up({ file, root = ROOT, env = process.env, log = () => {}
|
|
|
126
129
|
if (took) log(took);
|
|
127
130
|
// Its own loopback address for every published port, chosen once, so a second snowglobe starts beside this one.
|
|
128
131
|
const bind = await chooseBind({ root, state, project, env });
|
|
129
|
-
if (bind !==
|
|
132
|
+
if (bind !== ROUTER_ADDRESS) log(`${found.name} publishes its ports on ${bind}`);
|
|
133
|
+
// Routed by name unless it holds the router's own address (SG_BIND, or kept while it runs).
|
|
134
|
+
const routed = routerRuns({ root, env }) && bind !== ROUTER_ADDRESS;
|
|
135
|
+
if (routerRuns({ root, env }) && !routed)
|
|
136
|
+
log(`${found.name} is on ${ROUTER_ADDRESS}, the name router's address, so the router is not started and no other snowglobe is reached by name while it runs. To move it: snowglobe down, then up${env.SG_BIND ? ' without SG_BIND' : ''}`);
|
|
137
|
+
const rdir = routerDir(env);
|
|
138
|
+
const taken = (other) =>
|
|
139
|
+
new Error(
|
|
140
|
+
`${other.globe} (compose project ${other.project}) is up at ${globe.domain} already, and the name router sends a name to one snowglobe only: take it down first, or leave the domain line out of ${path} so ${found.name} gets its own`,
|
|
141
|
+
);
|
|
142
|
+
// Asked before anything starts; asked again under the router's lock when the route is written,
|
|
143
|
+
// for another up at the same domain in between.
|
|
144
|
+
const isUp = (other) => ownNginxUp(other, env);
|
|
145
|
+
if (routed) {
|
|
146
|
+
const other = readRoutes(rdir).find((r) => r.project !== project && r.domain === globe.domain && isUp(r.project));
|
|
147
|
+
if (other) throw taken(other);
|
|
148
|
+
}
|
|
130
149
|
// Checked before the render changes, so a refused file leaves the running globe's render.
|
|
131
150
|
const kept = readApps(p);
|
|
132
151
|
const badEnding = appEnding(kept);
|
|
@@ -172,10 +191,12 @@ export async function up({ file, root = ROOT, env = process.env, log = () => {}
|
|
|
172
191
|
throw new Error(`docker compose ${args.join(' ')} failed (exit ${res.status}). If a container crash-loops after you updated snowglobe, an image built earlier may be stale (the slingshot image is tagged by its commit, so a changed stack/slingshot/ folder alone does not rebuild it): run snowglobe down (without -v, which keeps your data), remove the image with docker rmi, then run up again`);
|
|
173
192
|
return res;
|
|
174
193
|
};
|
|
175
|
-
// The nginx
|
|
176
|
-
|
|
194
|
+
// The files nginx mounts one by one from the checkout, as the running nginx last started with
|
|
195
|
+
// them (stamped after a restart here): its template, and the network page and its config. A
|
|
196
|
+
// mounted file keeps the content it had when nginx started, so a new one needs a restart.
|
|
197
|
+
const templateFiles = ['stack/https/nginx.conf.template', 'stack/page/snowglobe.conf', 'stack/page/index.html'].map((f) => join(root, f));
|
|
177
198
|
const templateStamp = join(p.https, 'nginx-template.sha256');
|
|
178
|
-
const templateHash =
|
|
199
|
+
const templateHash = templateFiles.reduce((h, f) => h.update(existsSync(f) ? readFileSync(f) : '').update('\0'), createHash('sha256')).digest('hex');
|
|
179
200
|
const templateChanged = !existsSync(templateStamp) || readFileSync(templateStamp, 'utf8').trim() !== templateHash;
|
|
180
201
|
const put = (file, text) => {
|
|
181
202
|
writeFileSync(`${file}.tmp`, `${text}\n`);
|
|
@@ -231,8 +252,16 @@ export async function up({ file, root = ROOT, env = process.env, log = () => {}
|
|
|
231
252
|
composeEnv.SG_LEXICON_AUTHORITY_DID = envGet('LEX_AUTHORITY_DID', p.accounts);
|
|
232
253
|
log(`Pass 1: up (project ${project})`);
|
|
233
254
|
await upStack();
|
|
234
|
-
|
|
235
|
-
|
|
255
|
+
if (routed) {
|
|
256
|
+
const change = () => {
|
|
257
|
+
const other = claimDomain(rdir, { project, domain, bind, globe: found.name }, isUp, log);
|
|
258
|
+
if (other) throw taken(other);
|
|
259
|
+
};
|
|
260
|
+
await syncRouter({ dir: rdir, env, log, change });
|
|
261
|
+
log(`The name router sends ${domain} and every name under it to ${bind}`);
|
|
262
|
+
}
|
|
263
|
+
// A running nginx keeps the certificate files it started with, and the template and page files
|
|
264
|
+
// it was started with (the image fills the template in at start); new ones need a restart, whether or not the leaf was
|
|
236
265
|
// reissued. The globe's servers may have changed with the render, so a running nginx reloads them.
|
|
237
266
|
if (newCerts || templateChanged) {
|
|
238
267
|
compose(['restart', 'nginx'], { stdio: 'ignore' });
|
|
@@ -273,12 +302,6 @@ export async function up({ file, root = ROOT, env = process.env, log = () => {}
|
|
|
273
302
|
}
|
|
274
303
|
}
|
|
275
304
|
|
|
276
|
-
// init's accounts are on the seed PDS; run maps their handles.
|
|
277
|
-
for (const prefix of ['ALICE', 'BOB']) {
|
|
278
|
-
const handle = envGet(`${prefix}_HANDLE`, p.accounts);
|
|
279
|
-
if (handle) addName(handle, { root, state });
|
|
280
|
-
}
|
|
281
|
-
|
|
282
305
|
// What the file seeds. A rerun finds each account by its kept login.
|
|
283
306
|
for (const a of seedAccounts) await account({ ...a, file: p.accounts, root, state, env, log });
|
|
284
307
|
if (globe.lexicons.publish) await lexicons({ dir: resolve(dirname(path), globe.lexicons.publish), root, state, env, log });
|
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}`)),
|
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
|
@@ -156,8 +156,9 @@ export function renderGlobe(globe, base, { stateDir, globeDir = join(stateDir ??
|
|
|
156
156
|
// 1918 one, which the SSRF-protected fetch would refuse.
|
|
157
157
|
PDS_DISABLE_SSRF_PROTECTION: 'true',
|
|
158
158
|
NODE_EXTRA_CA_CERTS: '/https/ca.crt',
|
|
159
|
-
//
|
|
160
|
-
|
|
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,
|
|
161
162
|
NODE_OPTIONS: '--require /https/app-names.cjs',
|
|
162
163
|
},
|
|
163
164
|
volumes: [`${svc(p)}-data:/pds/data`, CA, APP_NAMES],
|
|
@@ -542,7 +543,7 @@ export function renderGlobe(globe, base, { stateDir, globeDir = join(stateDir ??
|
|
|
542
543
|
g.name,
|
|
543
544
|
`Holds groups, which are accounts of their own, with handles under ${host(g)} (${g.image ? shortImage(g.image) : 'built locally'})`,
|
|
544
545
|
]),
|
|
545
|
-
['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'],
|
|
546
547
|
on.relay && ['Relay', 'relay', 'relay', "Crawls each PDS and merges their changes into one firehose"],
|
|
547
548
|
on.jetstream && ['Jetstream', 'jetstream', 'jetstream', "The relay's firehose as JSON, for apps (and this page)"],
|
|
548
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/package.json
CHANGED
package/snowglobe.default.yml
CHANGED
|
@@ -7,14 +7,13 @@
|
|
|
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. The relay crawls it too, so
|
|
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
|
|
18
17
|
# Jetstream sees its writes.
|
|
19
18
|
# - name: other
|
|
20
19
|
# image: ghcr.io/bluesky-social/pds:0.4.5009
|
|
@@ -28,7 +27,7 @@ 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
32
|
# relay: auto # auto: on with Jetstream, which reads it, or more than one PDS
|
|
34
33
|
|
|
@@ -37,7 +36,7 @@ services: # these are the defaults, each true or false
|
|
|
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"
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
// Preloaded into each PDS (NODE_OPTIONS=--require, in the globe bin/snowglobe.mjs render writes), so a PDS can
|
|
2
|
-
// reach an app at https://<name>.app.<domain
|
|
3
|
-
//
|
|
2
|
+
// reach an OAuth client under the globe's domain, an app at https://<name>.app.<domain> or
|
|
3
|
+
// PDSls at https://pdsls.<domain>: it fetches the client's metadata and keys from there.
|
|
4
|
+
// Docker's DNS has no wildcard names, so every such name is
|
|
4
5
|
// looked up as nginx instead, which routes it by name (stack/https/nginx.conf.template). Only
|
|
5
6
|
// the address lookup changes: the request still carries the app's name, and TLS still
|
|
6
7
|
// checks the certificate for it against the snowglobe CA. Every other name resolves as usual.
|
|
7
8
|
//
|
|
8
|
-
// SG_RESOLVE_DOMAIN sets the domain whose names, itself included, go to nginx. Each PDS
|
|
9
|
-
//
|
|
10
|
-
// not know.
|
|
9
|
+
// SG_RESOLVE_DOMAIN sets the domain whose names, itself included, go to nginx. Each PDS and
|
|
10
|
+
// each group host sets it to the globe's domain; a group host also resolves its members'
|
|
11
|
+
// handles there, which Docker's DNS does not know.
|
|
11
12
|
'use strict';
|
|
12
13
|
const dns = require('dns');
|
|
13
14
|
|
|
@@ -37,7 +37,7 @@ location = /health/relay { proxy_pass $relay/xrpc/_health; }
|
|
|
37
37
|
location = /health/jetstream { proxy_pass $jetstream/; }
|
|
38
38
|
location = /health/slingshot { proxy_pass $slingshot/; }
|
|
39
39
|
location = /health/sync { proxy_pass $tap/health; }
|
|
40
|
-
location = /health/mail { proxy_pass $maildev/healthz; }
|
|
40
|
+
location = /health/mail { proxy_pass $maildev/api/healthz; }
|
|
41
41
|
location = /health/pdsls { proxy_pass $pdsls/_health; }
|
|
42
42
|
|
|
43
43
|
# Health and accounts for each account server (/health/<name>, /people/<name>/listRepos and
|
package/lib/hosts.mjs
DELETED
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
// The names snowglobe run maps to nginx, and the hosts file it binds over /etc/hosts. Pure:
|
|
2
|
-
// lib/commands/run.mjs reads the files and runs the command.
|
|
3
|
-
|
|
4
|
-
const NAME_RE = /^[A-Za-z0-9.-]+$/;
|
|
5
|
-
const words = (text) => (text ?? '').split(/\s+/).filter(Boolean);
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Every name a process under run should reach nginx at: the rendered globe's leaf names that are
|
|
9
|
-
* not wildcards (its page, each PDS, its services, plc.directory), each handle in `handles`
|
|
10
|
-
* (data/https/names), <name>.app.<domain> for each app in `apps`, and `extra`
|
|
11
|
-
* (HTTPS_RUN_EXTRA_NAMES, space-separated). In that order, each once.
|
|
12
|
-
*/
|
|
13
|
-
export function runNames({ leafNames, handles, apps, domain, extra }) {
|
|
14
|
-
return [
|
|
15
|
-
...new Set([
|
|
16
|
-
...words(leafNames).filter((n) => !n.startsWith('*')),
|
|
17
|
-
// One per line; a line that is not a name is skipped whole.
|
|
18
|
-
...(handles ?? '').split('\n').filter((n) => NAME_RE.test(n)),
|
|
19
|
-
...apps.map((a) => `${a.name}.app.${domain}`),
|
|
20
|
-
...words(extra),
|
|
21
|
-
]),
|
|
22
|
-
];
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* The hosts file: `etcHosts` (the machine's) without any line naming one of `names`, then each
|
|
27
|
-
* name on `bind`.
|
|
28
|
-
*/
|
|
29
|
-
export function hostsFile(etcHosts, names, bind) {
|
|
30
|
-
const ours = new Set(names);
|
|
31
|
-
const kept = etcHosts
|
|
32
|
-
.split('\n')
|
|
33
|
-
.filter((l, i, all) => i < all.length - 1 || l !== '')
|
|
34
|
-
.filter((l) => !l.trim().split(/\s+/).slice(1).some((n) => ours.has(n)));
|
|
35
|
-
return [...kept, '# snowglobe (bin/snowglobe.mjs run)', ...names.map((n) => `${bind} ${n}`), ''].join('\n');
|
|
36
|
-
}
|