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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/.env.example +28 -0
  2. package/LICENSE +21 -0
  3. package/README.md +148 -2
  4. package/bin/snowglobe.mjs +71 -0
  5. package/lib/apps.mjs +97 -0
  6. package/lib/args.mjs +29 -0
  7. package/lib/certs.mjs +211 -0
  8. package/lib/commands/account.mjs +85 -0
  9. package/lib/commands/app.mjs +86 -0
  10. package/lib/commands/certs.mjs +36 -0
  11. package/lib/commands/check.mjs +37 -0
  12. package/lib/commands/down.mjs +40 -0
  13. package/lib/commands/env.mjs +146 -0
  14. package/lib/commands/invite.mjs +48 -0
  15. package/lib/commands/lexicons.mjs +94 -0
  16. package/lib/commands/ls.mjs +72 -0
  17. package/lib/commands/render.mjs +63 -0
  18. package/lib/commands/run.mjs +109 -0
  19. package/lib/commands/trust.mjs +45 -0
  20. package/lib/commands/up.mjs +301 -0
  21. package/lib/config.mjs +514 -0
  22. package/lib/env.mjs +526 -0
  23. package/lib/hosts.mjs +36 -0
  24. package/lib/prebuilt.mjs +56 -0
  25. package/lib/render.mjs +601 -0
  26. package/lib/settings.mjs +156 -0
  27. package/lib/state.mjs +109 -0
  28. package/lib/trust.mjs +288 -0
  29. package/lib/up.mjs +54 -0
  30. package/lib/xrpc.mjs +49 -0
  31. package/package.json +55 -4
  32. package/snowglobe.default.yml +54 -0
  33. package/stack/docker-compose.relay.yml +105 -0
  34. package/stack/docker-compose.test.yml +41 -0
  35. package/stack/docker-compose.yml +179 -0
  36. package/stack/https/app-names.cjs +31 -0
  37. package/stack/https/dns/Dockerfile +10 -0
  38. package/stack/https/dns/dns.sh +40 -0
  39. package/stack/https/nginx.conf.template +104 -0
  40. package/stack/https/proxy/Dockerfile +8 -0
  41. package/stack/https/proxy/server.mjs +213 -0
  42. package/stack/init/init.sh +118 -0
  43. package/stack/page/fonts/OFL-jetbrains-mono.txt +93 -0
  44. package/stack/page/fonts/OFL-schibsted-grotesk.txt +93 -0
  45. package/stack/page/fonts/jetbrains-mono-latin.woff2 +0 -0
  46. package/stack/page/fonts/schibsted-grotesk-latin.woff2 +0 -0
  47. package/stack/page/index.html +409 -0
  48. package/stack/page/snowglobe.conf +59 -0
  49. package/stack/pdsls/Dockerfile +27 -0
  50. package/stack/pdsls/network.patch +294 -0
  51. package/stack/pdsls/server.mjs +192 -0
  52. package/stack/relay/Dockerfile +30 -0
  53. package/stack/relay/allow-private-hosts.patch +55 -0
  54. package/stack/slingshot/Dockerfile +36 -0
package/.env.example ADDED
@@ -0,0 +1,28 @@
1
+ # Database credentials — single source of truth for PLC and test postgres.
2
+ # Consumer overrides these to use their own postgres instance.
3
+ SG_DB_USER=postgres
4
+ # SG_DB_PASSWORD= (unset: snowglobe up makes a random one, in secrets.env in the snowglobe's state folder)
5
+ SG_DB_HOST=postgres
6
+ SG_DB_PORT=5432
7
+ SG_DB_NAME=plc
8
+
9
+ # Required: SMTP server for PDS email verification
10
+ # Consumer provides this — must be reachable from Docker network
11
+ SG_SMTP_URL=smtp://maildev:1025
12
+
13
+ # Optional: Override defaults
14
+ # The snowglobe's domain: its PDSes are <name>.<domain>. A domain under .test, .local or
15
+ # .localhost fails sign-in (a PDS refuses an OAuth client there); this default is ours and
16
+ # resolves to this machine.
17
+ # SG_DOMAIN=local.snowglobe.at
18
+ # SG_PDS_HOSTNAME=local.snowglobe.at
19
+ # SG_PDS_ADMIN_PASSWORD= # unset: up makes a random one and keeps it in the snowglobe's admin.env
20
+ # SG_HANDLE_DOMAIN=.local.snowglobe.at
21
+ # SG_SEED_ACCOUNTS=true
22
+
23
+ # Host ports — override to avoid conflicts with other local services
24
+ # SG_PDS_PORT=3000
25
+ # SG_PLC_PORT=2582
26
+ # SG_JETSTREAM_PORT=6008
27
+ # SG_JETSTREAM_METRICS_PORT=6009
28
+ # SG_TAP_PORT=2480
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 OpenMeet Team
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,149 @@
1
- # Temporary Holding Version
1
+ # Snowglobe
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Your own isolated [AT Protocol](https://atproto.com) network, for testing, research, and demos. A snowglobe has its own
4
+ PDS, PLC directory and Jetstream (and a relay, once there is more than one PDS), and nothing in it reaches Bluesky's network. It uses
5
+ https and the real network's sign-in (OAuth), so an app built for the real network runs against it
6
+ with no test-only code.
7
+
8
+ **Why?** Some things worth trying on atproto can hurt real users if they go wrong: a change to a lexicon,
9
+ a new OAuth flow, an app that writes records in a new way. A snowglobe is a network of your own, where a
10
+ mistake touches nobody. We use it to build against the spaces alpha PDS, a group host and atmo.rsvp.
11
+
12
+ **How?** Run one on your machine from this repo, or open the one we host,
13
+ [lighthouse.snowglobe.at](https://lighthouse.snowglobe.at). [snowglobe.at](https://snowglobe.at)
14
+ says more about the project.
15
+
16
+ ## What runs in a snowglobe
17
+
18
+ One file, `snowglobe.yml`, says what a snowglobe runs. [`snowglobe.default.yml`](snowglobe.default.yml)
19
+ is the one to start from. It gives you:
20
+
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; it also answers as
24
+ `plc.directory` for your app;
25
+ - Jetstream, a simple stream of every write on the network, for apps that follow it;
26
+ - Slingshot, which looks up a handle's DID and PDS inside the network;
27
+ - PDSls, to browse the network's records;
28
+ - the network's own page, with its services, accounts and live activity.
29
+
30
+ The file also holds commented examples for adding more, up to a full setup:
31
+ - more PDSes. With more than one, a relay turns on by itself: it merges every PDS's writes into one
32
+ stream, which Jetstream then follows;
33
+ - the permissioned-spaces alpha, a PDS build with shared spaces;
34
+ - a lexicon authority, the one account every PDS reads record type definitions (lexicons) from;
35
+ - Tap, which follows the stream and fetches whole repos for an app that keeps its own copy of the data;
36
+ - a group host, a server that runs groups as accounts of their own, each with a handle under the host's name;
37
+ - your own app.
38
+
39
+ [`docs/examples/atmo.yml`](docs/examples/atmo.yml) is a larger file, with three PDSes, a relay and a
40
+ lexicon authority, that [Example: atmo](docs/examples/atmo.md) builds on.
41
+
42
+ Every name is under one domain, `local.snowglobe.at`, served by nginx with a certificate from a
43
+ local CA that the snowglobe makes for itself. There is no AppView and no feed generator.
44
+ [The snowglobe](docs/snowglobe.md) shows the whole file and what each part does.
45
+
46
+ ## What you need
47
+
48
+ Every service in a snowglobe has its own https name under `local.snowglobe.at`, such as
49
+ `alice.local.snowglobe.at`. Handles, sign-in (OAuth) and DID documents all carry those names, so
50
+ your computer has to send the names to the snowglobe, and trust its certificate. Here is what to do.
51
+
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.
59
+
60
+ **After it is up, to open it in a browser or reach it from an app:**
61
+
62
+ 3. Make the names reach the snowglobe.
63
+ - Docker and your browser on the same computer: you usually do nothing, because public DNS answers
64
+ every name under `local.snowglobe.at` with `127.0.0.1`. If the browser says "server not found",
65
+ your router is dropping that answer. See
66
+ [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: the snowglobe's proxy runs with every `up`, on
68
+ `127.0.0.1:1080`; point your browser at it ([From a browser on your own computer](docs/browser.md)).
69
+ - A command started with `snowglobe run`: you do nothing, it sets the names up itself.
70
+ 4. Trust the snowglobe's certificate authority (CA). Run `snowglobe trust` on the machine that runs
71
+ Docker. For another machine, see [From a browser on your own computer](docs/browser.md).
72
+
73
+ **Why not just use ports?** The PDSes are not published on ports at all: only nginx (443), the proxy,
74
+ the PLC, Jetstream and MailDev are. A PDS's address is its name, and that name is what a DID document, a
75
+ handle lookup and an OAuth sign-in resolve to, so an app has to be told it is on a test network to
76
+ use a port instead. A snowglobe can still be small: a file with one PDS, the PLC and Jetstream
77
+ checks and renders ([The file and the commands](docs/snowglobe.md#the-file-and-the-commands)). It
78
+ keeps the https names, and they are what make an app run unchanged.
79
+
80
+ ## Quick start
81
+
82
+ ```bash
83
+ npm install -g @openmeet/snowglobe # puts the snowglobe command on your PATH
84
+ snowglobe up --default # the first run pulls and builds images, which takes a while
85
+ ```
86
+
87
+ To change what it runs, copy the default file into your folder, give it a name of its own on the
88
+ `globe:` line (`snowglobe` is the default file's, and a snowglobe belongs to the file that first
89
+ brought it up), edit it, and run `snowglobe up`:
90
+ `cp "$(npm root -g)/@openmeet/snowglobe/snowglobe.default.yml" snowglobe.yml`.
91
+ To keep the command with one project instead, run `npm install --save-dev @openmeet/snowglobe`, then
92
+ `npx snowglobe up --default` for the default, or `npx snowglobe up` once the folder has a
93
+ `snowglobe.yml` (copy it from `node_modules/@openmeet/snowglobe/snowglobe.default.yml`). Either
94
+ command also fits in a `package.json` script.
95
+
96
+ Now try it. `snowglobe run` runs a command that can reach the snowglobe by name, and `--cacert`
97
+ gives curl the snowglobe's certificate authority (CA), so it trusts the snowglobe's https:
98
+
99
+ ```bash
100
+ # Sign up a new account, then look up alice's account through Slingshot
101
+ snowglobe account carol CAROL # CAROL: the prefix of her login variables, such as CAROL_PASSWORD
102
+ snowglobe run curl -s --cacert ~/.local/share/snowglobe/ca/ca.crt \
103
+ 'https://slingshot.local.snowglobe.at/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=alice.local.snowglobe.at'
104
+ ```
105
+
106
+ To look around in a browser, you need two things: the browser has to trust the snowglobe's
107
+ 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,
109
+ which lists every service, account and write. If Docker runs in a dev container or on another
110
+ machine, your browser also needs the snowglobe's small proxy, which sends the snowglobe's names to
111
+ it; [From a browser on your own computer](docs/browser.md) walks through both.
112
+
113
+ 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,
115
+ once your browser can reach the snowglobe as above.
116
+
117
+ Stop the snowglobe with `snowglobe down`. Add `-v` to delete its data too, every account
118
+ included. Everything a snowglobe makes is kept in your own account, in
119
+ `~/.local/share/snowglobe` (or under `XDG_DATA_HOME`), and nothing in the checkout
120
+ ([Which snowglobe, and where it lives](docs/snowglobe.md#which-snowglobe-and-where-it-lives)).
121
+
122
+ To run your own app inside of a snowglobe, see [Your app in a snowglobe](docs/your-app.md).
123
+
124
+ ## Platforms
125
+
126
+ Snowglobe runs on Linux, and on Windows under WSL 2. Both are tested. macOS is not supported, but we'd love to.
127
+ If you try it on a Mac, please
128
+ [open an issue](https://github.com/OpenMeet-Team/snowglobe/issues) with what broke, or that it
129
+ worked, and we'd love to experiment with you.
130
+
131
+ ## Documentation
132
+
133
+ - [The snowglobe](docs/snowglobe.md): what a `snowglobe.yml` holds, the commands, the snowglobe's names, up and
134
+ down, and what it writes.
135
+ - [Your app in a snowglobe](docs/your-app.md): `snowglobe run`, and the tools that make an app's
136
+ accounts, invite codes, lexicons and routes.
137
+ - [From a browser on your own computer](docs/browser.md): the proxy, launch flags, and trusting the
138
+ CA.
139
+ - [How it works](docs/how-it-works.md): what runs and how it is wired, Slingshot, and the relay build.
140
+ - [Configuration](docs/configuration.md): the settings.
141
+ - [Testing Snowglobe](docs/testing.md): the test suite and the smoke run.
142
+ - [Example: atmo](docs/examples/atmo.md): an events app on a local snowglobe.
143
+ - The older plain-http stack (`npm run up`) is not in this version. Its code and README are in the
144
+ [v0.2.0 tag](https://github.com/OpenMeet-Team/snowglobe/tree/v0.2.0) (v0.1.0 has the first version of it).
145
+ - [Changelog](CHANGELOG.md): what changed in each version, and how to upgrade.
146
+
147
+ ## License
148
+
149
+ MIT
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env node
2
+ // snowglobe: run the globe a snowglobe.yml describes.
3
+ //
4
+ // check checks a snowglobe.yml
5
+ // ls lists the snowglobes on this machine
6
+ // render writes the https globe it describes into its state folder
7
+ // certs makes your CA, once, and the leaf nginx serves
8
+ // trust puts your CA in the system and browser trust stores (--remove takes it out)
9
+ // env prints the settings an app is handed, for eval "$(snowglobe env)"
10
+ // account makes an account on a PDS in the running globe
11
+ // invite mints an invite code on one of its PDSes
12
+ // lexicons publishes lexicons into its lexicon authority
13
+ // app routes https://<name>.app.<domain> to a port on this machine
14
+ // up brings the globe up, as the compose project named for its file (or <name>, -f <file>)
15
+ // down stops it (the same ways)
16
+ // run runs a command with the app's settings, and plc.directory pointed at the snowglobe
17
+ //
18
+ // A snowglobe is the snowglobe.yml in the folder you are in or the nearest one above (else
19
+ // SG_FILE, else the default). Its state is kept per user, not in a checkout: ~/.local/share/
20
+ // snowglobe/<globe>/ (XDG_DATA_HOME, %LOCALAPPDATA% on Windows), and its compose project is the
21
+ // file's `globe:` unless SG_PROJECT is set (lib/state.mjs).
22
+ //
23
+ // Each command is a file in lib/commands/, which says what it does and why; the usage below comes
24
+ // from them. Exit 2 for a usage mistake, 1 when the globe refuses, 3 when up or down refuse the
25
+ // shared devnet; certs keeps 1 for --check's answer, so it fails with 2, and run exits with its
26
+ // command's code.
27
+
28
+ import { readArgs } from '../lib/args.mjs';
29
+ import { Refused, UsageError, adoptOldNames } from '../lib/env.mjs';
30
+
31
+ // Loaded when it runs, so check and render do without the certificate library.
32
+ const COMMANDS = {
33
+ check: () => import('../lib/commands/check.mjs'),
34
+ ls: () => import('../lib/commands/ls.mjs'),
35
+ render: () => import('../lib/commands/render.mjs'),
36
+ certs: () => import('../lib/commands/certs.mjs'),
37
+ trust: () => import('../lib/commands/trust.mjs'),
38
+ env: () => import('../lib/commands/env.mjs'),
39
+ account: () => import('../lib/commands/account.mjs'),
40
+ invite: () => import('../lib/commands/invite.mjs'),
41
+ lexicons: () => import('../lib/commands/lexicons.mjs'),
42
+ app: () => import('../lib/commands/app.mjs'),
43
+ up: () => import('../lib/commands/up.mjs'),
44
+ down: () => import('../lib/commands/down.mjs'),
45
+ run: () => import('../lib/commands/run.mjs'),
46
+ };
47
+
48
+ const [name, ...args] = process.argv.slice(2);
49
+ const helped = ['--help', '-h', 'help'].includes(name ?? '');
50
+ const mod = !helped && Object.hasOwn(COMMANDS, name ?? '') ? await COMMANDS[name]() : null;
51
+ // A command that takes any words reads them itself.
52
+ const parsed = mod && (mod.command.parse ? mod.command.parse(args) : readArgs(mod.command, args));
53
+ if (!parsed) {
54
+ const lines = await Promise.all(Object.values(COMMANDS).map(async (load) => `snowglobe ${(await load()).command.usage}`));
55
+ const usage = `Usage: ${lines.join('\n ')}`;
56
+ // --help asked for it, so it is the answer; anything else is a mistake.
57
+ if (helped) {
58
+ console.log(usage);
59
+ process.exit(0);
60
+ }
61
+ console.error(usage);
62
+ process.exit(2);
63
+ }
64
+ // DEVNET_* and SNOWGLOBE_* settings, the old names, become SG_* before any command reads them.
65
+ adoptOldNames();
66
+ try {
67
+ process.exit((await mod.run(parsed)) ?? 0);
68
+ } catch (e) {
69
+ console.error(`snowglobe ${mod.command.name}: ${e.message}`);
70
+ process.exit(e instanceof Refused ? e.exitCode : e instanceof UsageError ? 2 : 1);
71
+ }
package/lib/apps.mjs ADDED
@@ -0,0 +1,97 @@
1
+ // Apps on this machine, routed through the https globe's nginx: https://<name>.app.<domain> goes
2
+ // to a port here, for an app's pages, its OAuth client metadata and its keys, at an https origin
3
+ // the globe's PDSes and snowglobe run's processes both reach. An app that hands out handles of its own,
4
+ // as a PDS does, also claims an ending: every name under <ending>.<domain> goes to it, so a handle
5
+ // there resolves at the app.
6
+ //
7
+ // The routes are kept in https/apps in the snowglobe's state folder, "<name> <port> [<ending>]" per
8
+ // line. From them and the
9
+ // rendered globe's page facts (globe/page.json), nginx's apps.conf and apps.map and the
10
+ // network page's snowglobe.json are rendered into https/nginx/ there (mounted at /etc/nginx/snowglobe/;
11
+ // see stack/https/nginx.conf.template). The renderers are pure; renderRoutes writes what they render, for
12
+ // bin/snowglobe.mjs app (lib/commands/app.mjs), which keeps the routes.
13
+
14
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
15
+ import { join } from 'node:path';
16
+ import { ROOT, globeFacts, paths } from './env.mjs';
17
+
18
+ const HEADER = '# Written by bin/snowglobe.mjs app from https/apps; rerun it instead of editing.\n';
19
+
20
+ /**
21
+ * The first saved route whose ending is `app`, or null. app.<domain> is where the apps are, so an
22
+ * ending of that name (saved before app was reserved) would capture every app route.
23
+ */
24
+ export const appEnding = (apps) => apps.find((a) => a.ending === 'app') ?? null;
25
+
26
+ export const appEndingMessage = (file, a) =>
27
+ `${file}: the line "${formatApps([a]).trim()}" claims the ending app, which is where the apps are (${a.name}.app.<domain>); ` +
28
+ `remove the route (bin/snowglobe.mjs app --remove ${a.name}) or give it another ending`;
29
+
30
+ /** The routes in an apps file's text: [{ name, port, ending }], ending null when none. */
31
+ export function parseApps(text) {
32
+ return text
33
+ .split('\n')
34
+ .map((l) => l.split(/\s+/).filter(Boolean))
35
+ .filter((f) => f.length >= 2)
36
+ .map(([name, port, ending]) => ({ name, port: Number(port), ending: ending ?? null }));
37
+ }
38
+
39
+ export const formatApps = (apps) => apps.map((a) => `${a.name} ${a.port}${a.ending ? ` ${a.ending}` : ''}\n`).join('');
40
+
41
+ const upstream = (a) => `snowglobe_app_${a.name.replace(/-/g, '_')}`;
42
+
43
+ /**
44
+ * nginx's part for the routes: apps.conf, one upstream per app at host.docker.internal:<port> and,
45
+ * for an app that claimed an ending, a server for *.<ending>.<domain>; apps.map, one line per name.
46
+ */
47
+ export function renderApps(apps, domain) {
48
+ const conf =
49
+ HEADER +
50
+ apps.map((a) => `upstream ${upstream(a)} { server host.docker.internal:${a.port}; }\n`).join('') +
51
+ // Host is passed through (the http block sets it), so the app answers
52
+ // /.well-known/atproto-did for each handle. nginx takes the longer of two matching
53
+ // wildcards, so these win over a PDS's *.<domain>.
54
+ apps
55
+ .filter((a) => a.ending)
56
+ .map(
57
+ (a) =>
58
+ `\n# Handles under ${a.ending}.${domain} go to ${a.name}.\nserver {\n listen 443 ssl;\n server_name *.${a.ending}.${domain};\n` +
59
+ ` location / {\n proxy_pass http://${upstream(a)};\n }\n}\n`,
60
+ )
61
+ .join('');
62
+ const map = HEADER + apps.map((a) => `${a.name}.app.${domain} ${upstream(a)};\n`).join('');
63
+ return { 'apps.conf': conf, 'apps.map': map };
64
+ }
65
+
66
+ /** The network page's snowglobe.json: the rendered globe's page facts, with a link to each routed app. */
67
+ export function renderPage(page, apps, domain = page.domain) {
68
+ const links = [...(page.apps ?? []), ...apps.map((a) => ({ name: a.name, url: `https://${a.name}.app.${domain}` }))];
69
+ return JSON.stringify({ ...page, apps: links }, null, 2) + '\n';
70
+ }
71
+
72
+ // Written aside and renamed, so nginx never reads half a file.
73
+ function put(file, text) {
74
+ writeFileSync(`${file}.tmp`, text);
75
+ renameSync(`${file}.tmp`, file);
76
+ }
77
+
78
+ /** The routes kept in https/apps (`p` from paths()), [] when there are none. */
79
+ export const readApps = (p) => (existsSync(p.apps) ? parseApps(readFileSync(p.apps, 'utf8')) : []);
80
+
81
+ /** Keep `apps` as the routes in https/apps. */
82
+ export const writeApps = (p, apps) => put(p.apps, formatApps(apps));
83
+
84
+ /** Render https/nginx/ from the routes and the rendered globe. */
85
+ export function renderRoutes({ root = ROOT, state, env = process.env } = {}) {
86
+ const p = paths(root, state);
87
+ const pageJson = join(p.globe, 'page.json');
88
+ if (!existsSync(pageJson)) throw new Error(`No ${pageJson}: run bin/snowglobe.mjs up, which renders it`);
89
+ const { domain } = globeFacts({ root, state, env });
90
+ const apps = readApps(p);
91
+ const bad = appEnding(apps);
92
+ if (bad) throw new Error(appEndingMessage(p.apps, bad));
93
+ const out = join(p.https, 'nginx');
94
+ mkdirSync(out, { recursive: true });
95
+ for (const [name, text] of Object.entries(renderApps(apps, domain))) put(join(out, name), text);
96
+ put(join(out, 'snowglobe.json'), renderPage(JSON.parse(readFileSync(pageJson, 'utf8')), apps));
97
+ }
package/lib/args.mjs ADDED
@@ -0,0 +1,29 @@
1
+ // A snowglobe command's words, read with util.parseArgs the way the CLI has always read them: an
2
+ // option's value is the next word even when it starts with a dash (--out -x), and every other
3
+ // word that starts with one is refused, as are --name=value and a bare --. parseArgs in strict
4
+ // mode differs on each of those, so it reads loosely here and its tokens are checked instead.
5
+
6
+ import { parseArgs } from 'node:util';
7
+
8
+ /**
9
+ * Read `args` for `command`, a command's descriptor: its options (as parseArgs takes them), how
10
+ * many positionals it takes ([min, max]), and `accepts`, any rule on the shape beyond those.
11
+ * Returns { values, positionals, tokens }, or null for words the command does not take.
12
+ */
13
+ export function readArgs(command, args) {
14
+ const options = command.options ?? {};
15
+ const { values, positionals, tokens } = parseArgs({ args, options, strict: false, allowPositionals: true, tokens: true });
16
+ for (const t of tokens) {
17
+ if (t.kind === 'option-terminator') return null;
18
+ if (t.kind === 'positional' && t.value.startsWith('-')) return null;
19
+ if (t.kind === 'option') {
20
+ if (!Object.hasOwn(options, t.name) || t.inlineValue) return null;
21
+ // A string option as the last word, with no value after it.
22
+ if (options[t.name].type === 'string' && t.value === undefined) return null;
23
+ }
24
+ }
25
+ const [min, max] = command.positionals ?? [0, 0];
26
+ if (positionals.length < min || positionals.length > max) return null;
27
+ const parsed = { values, positionals, tokens };
28
+ return !command.accepts || command.accepts(parsed) ? parsed : null;
29
+ }
package/lib/certs.mjs ADDED
@@ -0,0 +1,211 @@
1
+ // The https globe's certificates: one local CA per user, made once (<state root>/ca), and one
2
+ // leaf per snowglobe, which nginx serves for every name: each PDS and its handles, the globe's services, plc.directory, every app at
3
+ // <name>.app.<domain> (one wildcard), and the handles under each ending an app claimed with
4
+ // bin/snowglobe.mjs app. Made here with @peculiar/x509, so no openssl is needed.
5
+ //
6
+ // Files: ca.crt and ca.key in the CA's folder (`caDir`), leaf.crt and leaf.key in the snowglobe's
7
+ // (`dir`). An existing CA is kept, so a browser or OS that trusts ca.crt keeps trusting every
8
+ // snowglobe; only the leaf is reissued, and only when it lacks a name or another CA signed it.
9
+ // Delete the CA's ca.* to start over, and then trust the new ca.crt again everywhere the old one
10
+ // was trusted.
11
+ //
12
+ // The CA can only vouch for servers under the default domain, the domain it was made for
13
+ // and plc.directory (name constraints, serverAuth only, no IP addresses), so
14
+ // 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 another
16
+ // domain made after the CA, means a new CA, trusted again.
17
+ //
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.
20
+
21
+ import 'reflect-metadata';
22
+ import { AsnConvert } from '@peculiar/asn1-schema';
23
+ import { GeneralName, GeneralSubtree, GeneralSubtrees, NameConstraints, id_ce_nameConstraints } from '@peculiar/asn1-x509';
24
+ import * as x509 from '@peculiar/x509';
25
+ import { X509Certificate as NodeCertificate, createPrivateKey, randomBytes, webcrypto } from 'node:crypto';
26
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
27
+ import { join } from 'node:path';
28
+ import { DEFAULT_DOMAIN } from './config.mjs';
29
+
30
+ x509.cryptoProvider.set(webcrypto);
31
+
32
+ const EC = { name: 'ECDSA', namedCurve: 'P-256' };
33
+ const SIGN = { name: 'ECDSA', hash: 'SHA-256' };
34
+ const DAY = 24 * 60 * 60 * 1000;
35
+ // 397 days: under the 398-day ceiling browsers apply to server certificates.
36
+ const LEAF_DAYS = 397;
37
+ const CA_DAYS = 730;
38
+ // Names every globe's CA vouches for besides its domain: the stand-in for the real PLC directory.
39
+ const FIXED = ['plc.directory'];
40
+
41
+ // Every name the leaf must carry: the rendered globe's (its page, each PDS and its handles, its
42
+ // services, plc.directory and *.app.<domain>), *.<ending>.<domain> for each ending an app
43
+ // claimed, and `extra` (HTTPS_EXTRA_NAMES, space-separated).
44
+ export function leafNames(p, domain, extra = '') {
45
+ if (!existsSync(p.leafNames)) throw new Error(`No ${p.leafNames}: run snowglobe up, which renders it`);
46
+ const words = (text) => text.split(/\s+/).filter(Boolean);
47
+ const endings = existsSync(p.apps)
48
+ ? readFileSync(p.apps, 'utf8').split('\n').map(words).filter((f) => f.length >= 3).map((f) => `*.${f[2]}.${domain}`)
49
+ : [];
50
+ return [...new Set([...words(readFileSync(p.leafNames, 'utf8')), ...endings, ...words(extra)])];
51
+ }
52
+
53
+ // The names a certificate carries, from its subjectAltName.
54
+ function sans(pem) {
55
+ const alt = new NodeCertificate(pem).subjectAltName ?? '';
56
+ return alt.split(', ').filter((s) => s.startsWith('DNS:')).map((s) => s.slice(4));
57
+ }
58
+
59
+ // True when there is no leaf, or it lacks one of `names`, or the CA in `caDir` did not sign it
60
+ // (a leaf left from another CA is not trusted by whoever trusts this one).
61
+ export function leafLacks(dir, names, caDir = dir) {
62
+ const crt = join(dir, 'leaf.crt');
63
+ if (!existsSync(crt) || !existsSync(join(dir, 'leaf.key'))) return true;
64
+ const leaf = readFileSync(crt, 'utf8');
65
+ const have = new Set(sans(leaf));
66
+ if (names.some((n) => !have.has(n))) return true;
67
+ const caCrt = join(caDir, 'ca.crt');
68
+ return existsSync(caCrt) && !new NodeCertificate(leaf).verify(new NodeCertificate(readFileSync(caCrt, 'utf8')).publicKey);
69
+ }
70
+
71
+ // The DNS names a CA may vouch for (its permitted subtrees), or null if it is unconstrained.
72
+ function permitted(ca) {
73
+ const ext = ca.extensions.find((e) => e.type === id_ce_nameConstraints);
74
+ if (!ext) return null;
75
+ const nc = AsnConvert.parse(ext.value, NameConstraints);
76
+ return (nc.permittedSubtrees ?? []).map((s) => s.base.dNSName).filter(Boolean);
77
+ }
78
+
79
+ const within = (name, bases) => bases.some((b) => name === b || name.endsWith(`.${b}`));
80
+
81
+ // A positive serial number of 16 random bytes.
82
+ function serial() {
83
+ const b = randomBytes(16);
84
+ b[0] &= 0x7f;
85
+ b[0] |= 0x01;
86
+ return b.toString('hex');
87
+ }
88
+
89
+ const pem = (label, der) =>
90
+ `-----BEGIN ${label}-----\n${Buffer.from(der).toString('base64').match(/.{1,64}/g).join('\n')}\n-----END ${label}-----\n`;
91
+
92
+ async function importSigningKey(keyPem) {
93
+ const key = createPrivateKey(keyPem);
94
+ const curve = key.asymmetricKeyDetails?.namedCurve;
95
+ if (key.asymmetricKeyType !== 'ec' || curve !== 'prime256v1') throw new Error('ca.key is not a P-256 key');
96
+ return webcrypto.subtle.importKey('pkcs8', key.export({ format: 'der', type: 'pkcs8' }), EC, false, ['sign']);
97
+ }
98
+
99
+ async function makeCa(domain, now) {
100
+ const keys = await webcrypto.subtle.generateKey(EC, true, ['sign', 'verify']);
101
+ // Each CA has a name of its own. Two CAs with one name confuse a browser that trusts one of
102
+ // them: Firefox checks the other's leaf against the trusted key and calls it a bad signature,
103
+ // with no way to click through, where an unknown CA could be.
104
+ const name = `snowglobe local CA ${now.toISOString().slice(0, 10)} ${randomBytes(3).toString('hex')}`;
105
+ const subtree = (gn) => new GeneralSubtree({ base: new GeneralName(gn) });
106
+ const constraints = new NameConstraints({
107
+ // One CA serves every snowglobe, so the default domain is always in, whatever domain the
108
+ // snowglobe that makes it is on.
109
+ permittedSubtrees: new GeneralSubtrees([...new Set([DEFAULT_DOMAIN, domain, ...FIXED])].map((d) => subtree({ dNSName: d }))),
110
+ excludedSubtrees: new GeneralSubtrees([subtree({ iPAddress: '0.0.0.0/0' }), subtree({ iPAddress: '::/0' })]),
111
+ });
112
+ const cert = await x509.X509CertificateGenerator.createSelfSigned({
113
+ serialNumber: serial(),
114
+ name: `O=snowglobe, CN=${name}`,
115
+ notBefore: now,
116
+ notAfter: new Date(now.getTime() + CA_DAYS * DAY),
117
+ signingAlgorithm: SIGN,
118
+ keys,
119
+ extensions: [
120
+ new x509.BasicConstraintsExtension(true, 0, true),
121
+ new x509.KeyUsagesExtension(x509.KeyUsageFlags.keyCertSign | x509.KeyUsageFlags.cRLSign, true),
122
+ new x509.ExtendedKeyUsageExtension([x509.ExtendedKeyUsage.serverAuth]),
123
+ new x509.Extension(id_ce_nameConstraints, true, AsnConvert.serialize(constraints)),
124
+ await x509.SubjectKeyIdentifierExtension.create(keys.publicKey),
125
+ ],
126
+ });
127
+ const key = pem('PRIVATE KEY', await webcrypto.subtle.exportKey('pkcs8', keys.privateKey));
128
+ return { cert, key };
129
+ }
130
+
131
+ // Make the CA (in `caDir`, `dir` by default) if there is none, and issue the leaf (in `dir`) if it
132
+ // lacks a name of `names` (or `force`). Returns { newCa, newLeaf }. Progress goes to `log`; no key
133
+ // is ever logged.
134
+ export async function ensureCerts({ dir, caDir = dir, domain, names, force = false, now = new Date(), log = () => {} }) {
135
+ mkdirSync(dir, { recursive: true });
136
+ mkdirSync(caDir, { recursive: true });
137
+ const f = (n) => join(dir, n);
138
+ const c = (n) => join(caDir, n);
139
+ let newCa = false;
140
+ let ca;
141
+ let caKey;
142
+ if (existsSync(c('ca.crt')) && existsSync(c('ca.key'))) {
143
+ ca = new x509.X509Certificate(readFileSync(c('ca.crt'), 'utf8'));
144
+ caKey = await importSigningKey(readFileSync(c('ca.key'), 'utf8'));
145
+ log(`Keeping the existing CA: ${ca.subject}`);
146
+ } else if (existsSync(c('ca.crt')) || existsSync(c('ca.key'))) {
147
+ throw new Error(
148
+ `Only one of ${c('ca.crt')} and ca.key exists. Restore the other, or delete both to make a new CA (which must then be trusted again).`,
149
+ );
150
+ } else {
151
+ const made = await makeCa(domain, now);
152
+ writeFileSync(c('ca.key'), made.key, { mode: 0o600, flag: 'wx' });
153
+ writeFileSync(c('ca.crt'), made.cert.toString('pem') + '\n', { mode: 0o644 });
154
+ ca = made.cert;
155
+ caKey = await importSigningKey(made.key);
156
+ newCa = true;
157
+ log(`Made a new CA: ${ca.subject}`);
158
+ }
159
+
160
+ if (!force && !leafLacks(dir, names, caDir)) {
161
+ log('The leaf already carries every name; kept');
162
+ return { newCa, newLeaf: false };
163
+ }
164
+
165
+ // Refused before anything is written, so the leaf nginx serves stays as it is.
166
+ const bases = permitted(ca);
167
+ const outside = bases ? names.filter((n) => !within(n.replace(/^\*\./, ''), bases)) : [];
168
+ if (outside.length)
169
+ throw new Error(
170
+ `The CA cannot vouch for ${outside.join(' ')} (it vouches for ${bases.join(', ')}), so the leaf is unchanged. ` +
171
+ `The domain ${domain} is not one this CA was made for. To make a new CA that is: delete the folder ${caDir} and run up again ` +
172
+ '(browsers and systems that trusted the old CA must then trust the new one).',
173
+ );
174
+
175
+ const keys = await webcrypto.subtle.generateKey(EC, true, ['sign', 'verify']);
176
+ const leaf = await x509.X509CertificateGenerator.create({
177
+ serialNumber: serial(),
178
+ subject: `O=snowglobe, CN=${domain}`,
179
+ // The CA's subject as its own bytes. Rebuilt from text, its strings come out PrintableString
180
+ // where the CA has UTF8String; openssl and Node match names loosely and accept that, but Go
181
+ // (the relay, TAP) matches the bytes, and calls the CA unknown.
182
+ issuer: ca.subjectName,
183
+ notBefore: now,
184
+ notAfter: new Date(now.getTime() + LEAF_DAYS * DAY),
185
+ signingAlgorithm: SIGN,
186
+ publicKey: keys.publicKey,
187
+ signingKey: caKey,
188
+ extensions: [
189
+ new x509.BasicConstraintsExtension(false, undefined, true),
190
+ new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature, true),
191
+ new x509.ExtendedKeyUsageExtension([x509.ExtendedKeyUsage.serverAuth]),
192
+ await x509.SubjectKeyIdentifierExtension.create(keys.publicKey),
193
+ await x509.AuthorityKeyIdentifierExtension.create(ca.publicKey),
194
+ new x509.SubjectAlternativeNameExtension(names.map((value) => ({ type: 'dns', value }))),
195
+ ],
196
+ });
197
+ const leafPem = leaf.toString('pem') + '\n';
198
+ if (!new NodeCertificate(leafPem).verify(new NodeCertificate(ca.toString('pem')).publicKey))
199
+ throw new Error('The new leaf does not check out against the CA; the leaf is unchanged');
200
+
201
+ // Written in place, not renamed: nginx's single-file mounts follow the file, not the name.
202
+ writeFileSync(f('leaf.key'), pem('PRIVATE KEY', await webcrypto.subtle.exportKey('pkcs8', keys.privateKey)), { mode: 0o600 });
203
+ writeFileSync(f('leaf.crt'), leafPem, { mode: 0o644 });
204
+ // nginx reads the leaf key as root inside its container.
205
+ chmodSync(f('leaf.key'), 0o600);
206
+ chmodSync(f('leaf.crt'), 0o644);
207
+ chmodSync(c('ca.key'), 0o600);
208
+ log(`Leaf names: ${names.join(' ')}`);
209
+ log(`Leaf valid until ${leaf.notAfter.toISOString()}`);
210
+ return { newCa, newLeaf: true };
211
+ }