@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.
- package/.env.example +28 -0
- package/LICENSE +21 -0
- package/README.md +148 -2
- package/bin/snowglobe.mjs +71 -0
- package/lib/apps.mjs +97 -0
- package/lib/args.mjs +29 -0
- package/lib/certs.mjs +211 -0
- package/lib/commands/account.mjs +85 -0
- package/lib/commands/app.mjs +86 -0
- package/lib/commands/certs.mjs +36 -0
- package/lib/commands/check.mjs +37 -0
- package/lib/commands/down.mjs +40 -0
- package/lib/commands/env.mjs +146 -0
- package/lib/commands/invite.mjs +48 -0
- package/lib/commands/lexicons.mjs +94 -0
- package/lib/commands/ls.mjs +72 -0
- package/lib/commands/render.mjs +63 -0
- package/lib/commands/run.mjs +109 -0
- package/lib/commands/trust.mjs +45 -0
- package/lib/commands/up.mjs +301 -0
- package/lib/config.mjs +514 -0
- package/lib/env.mjs +526 -0
- package/lib/hosts.mjs +36 -0
- package/lib/prebuilt.mjs +56 -0
- package/lib/render.mjs +601 -0
- package/lib/settings.mjs +156 -0
- package/lib/state.mjs +109 -0
- package/lib/trust.mjs +288 -0
- package/lib/up.mjs +54 -0
- package/lib/xrpc.mjs +49 -0
- package/package.json +55 -4
- package/snowglobe.default.yml +54 -0
- package/stack/docker-compose.relay.yml +105 -0
- package/stack/docker-compose.test.yml +41 -0
- package/stack/docker-compose.yml +179 -0
- package/stack/https/app-names.cjs +31 -0
- package/stack/https/dns/Dockerfile +10 -0
- package/stack/https/dns/dns.sh +40 -0
- package/stack/https/nginx.conf.template +104 -0
- package/stack/https/proxy/Dockerfile +8 -0
- package/stack/https/proxy/server.mjs +213 -0
- package/stack/init/init.sh +118 -0
- package/stack/page/fonts/OFL-jetbrains-mono.txt +93 -0
- package/stack/page/fonts/OFL-schibsted-grotesk.txt +93 -0
- package/stack/page/fonts/jetbrains-mono-latin.woff2 +0 -0
- package/stack/page/fonts/schibsted-grotesk-latin.woff2 +0 -0
- package/stack/page/index.html +409 -0
- package/stack/page/snowglobe.conf +59 -0
- package/stack/pdsls/Dockerfile +27 -0
- package/stack/pdsls/network.patch +294 -0
- package/stack/pdsls/server.mjs +192 -0
- package/stack/relay/Dockerfile +30 -0
- package/stack/relay/allow-private-hosts.patch +55 -0
- package/stack/slingshot/Dockerfile +36 -0
package/package.json
CHANGED
|
@@ -1,6 +1,57 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openmeet/snowglobe",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.3.0-rc.1",
|
|
4
|
+
"description": "Standalone, testable ATProto development network for local development and CI",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"snowglobe": "bin/snowglobe.mjs"
|
|
8
|
+
},
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"author": "OpenMeet Team",
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "https://github.com/OpenMeet-Team/snowglobe.git"
|
|
14
|
+
},
|
|
15
|
+
"homepage": "https://snowglobe.at",
|
|
16
|
+
"keywords": [
|
|
17
|
+
"atproto",
|
|
18
|
+
"bluesky",
|
|
19
|
+
"pds",
|
|
20
|
+
"jetstream",
|
|
21
|
+
"docker",
|
|
22
|
+
"development",
|
|
23
|
+
"testing"
|
|
24
|
+
],
|
|
25
|
+
"scripts": {
|
|
26
|
+
"test": "vitest run",
|
|
27
|
+
"test:watch": "vitest"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@atproto/api": "^0.15.0",
|
|
31
|
+
"@types/ws": "^8.5.0",
|
|
32
|
+
"typescript": "^5.7.0",
|
|
33
|
+
"vitest": "^3.0.0",
|
|
34
|
+
"ws": "^8.18.0"
|
|
35
|
+
},
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=20"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@peculiar/asn1-schema": "2.11.0",
|
|
41
|
+
"@peculiar/asn1-x509": "2.11.0",
|
|
42
|
+
"@peculiar/x509": "2.2.0",
|
|
43
|
+
"reflect-metadata": "0.2.2",
|
|
44
|
+
"yaml": "^2.9.1"
|
|
45
|
+
},
|
|
46
|
+
"files": [
|
|
47
|
+
"bin/",
|
|
48
|
+
"lib/",
|
|
49
|
+
"stack/",
|
|
50
|
+
"!stack/**/*.test.mjs",
|
|
51
|
+
"snowglobe.default.yml",
|
|
52
|
+
".env.example"
|
|
53
|
+
],
|
|
54
|
+
"publishConfig": {
|
|
55
|
+
"access": "public"
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# The default snowglobe: one current PDS, a PLC directory and Jetstream, all at https names, with
|
|
2
|
+
# Slingshot and PDSls (the record browser) beside them.
|
|
3
|
+
# Copy this file to snowglobe.yml (here, or in your app's repo) and run bin/snowglobe.mjs up.
|
|
4
|
+
# The commented sections below add more; docs/snowglobe.md says what each one does, and
|
|
5
|
+
# docs/examples/atmo.yml is the whole thing: three PDSes, a relay and a lexicon authority.
|
|
6
|
+
|
|
7
|
+
globe: snowglobe
|
|
8
|
+
domain: local.snowglobe.at # every name below is under it
|
|
9
|
+
|
|
10
|
+
pds:
|
|
11
|
+
- name: main # https://main.local.snowglobe.at
|
|
12
|
+
image: ghcr.io/bluesky-social/pds:0.4.5037 # a current release
|
|
13
|
+
bare_handles: true # its handles are <account>.local.snowglobe.at
|
|
14
|
+
# A second PDS, with handles <name>.other.local.snowglobe.at. With more than one PDS the
|
|
15
|
+
# relay turns on, so Jetstream sees every PDS's writes.
|
|
16
|
+
# - name: other
|
|
17
|
+
# image: ghcr.io/bluesky-social/pds:0.4.5009
|
|
18
|
+
#
|
|
19
|
+
# The permissioned-spaces alpha. Invites are required, and it is the PDS to name as the
|
|
20
|
+
# lexicon authority below. Its short name is pinned by digest.
|
|
21
|
+
# - name: alpha
|
|
22
|
+
# image: spaces-alpha
|
|
23
|
+
# invites: required
|
|
24
|
+
|
|
25
|
+
services: # these are the defaults, each true or false
|
|
26
|
+
jetstream: true
|
|
27
|
+
slingshot: true # answers a handle's DID and PDS inside the network; needs jetstream
|
|
28
|
+
pdsls: true # https://pdsls.local.snowglobe.at, a browser for the network's records
|
|
29
|
+
tap: false # sends an app the events of one collection (true, or a map; see docs/snowglobe.md)
|
|
30
|
+
# relay: auto # auto: on when there is more than one PDS
|
|
31
|
+
|
|
32
|
+
# Where every PDS resolves lexicons (NSIDs) from. Name a PDS that is in this file.
|
|
33
|
+
# lexicons:
|
|
34
|
+
# authority: alpha
|
|
35
|
+
# publish: lexicons # a folder next to this file; up publishes every lexicon in it
|
|
36
|
+
|
|
37
|
+
# A group host runs beside the PDSes, at https://gh.local.snowglobe.at.
|
|
38
|
+
# grouphosts:
|
|
39
|
+
# - name: gh
|
|
40
|
+
# kind: opensocial
|
|
41
|
+
# build: ../atproto-group-host # a folder with a Dockerfile, from this file's folder
|
|
42
|
+
|
|
43
|
+
accounts: # <name>@<pds>; up makes each one
|
|
44
|
+
- alice@main
|
|
45
|
+
- bob@main
|
|
46
|
+
|
|
47
|
+
# Your app, reached at https://myapp.app.local.snowglobe.at, with settings from the snowglobe.
|
|
48
|
+
# `snowglobe env` hands them over; docs/your-app.md has the names.
|
|
49
|
+
# apps:
|
|
50
|
+
# - name: myapp
|
|
51
|
+
# local: { port: 5173 }
|
|
52
|
+
# env:
|
|
53
|
+
# PUBLIC_ATPROTO_PLC_URL: ${services.plc.url}
|
|
54
|
+
# PUBLIC_ATPROTO_SIGNUP_PDS: ${pds.main.url}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# A local relay that crawls every PDS, so Jetstream and TAP see one merged stream, as they would
|
|
2
|
+
# in production. Read by lib/render.mjs when the relay is on; not meant to be run directly (see
|
|
3
|
+
# docker-compose.yml).
|
|
4
|
+
#
|
|
5
|
+
# PDSes --> relay (:2470) --> Jetstream (:6008)
|
|
6
|
+
# \--> TAP (:2480)
|
|
7
|
+
#
|
|
8
|
+
# The relay joins the PDSes' network namespace: it connects to each host at the
|
|
9
|
+
# localhost URL in its accounts' DID documents, and checks that each account's DID
|
|
10
|
+
# document names the host the event came from. TAP joins too, because a backfill
|
|
11
|
+
# fetches repos from those same URLs. Jetstream only reads the relay, so it stays
|
|
12
|
+
# on the bridge network and reaches the relay as pds:2470.
|
|
13
|
+
#
|
|
14
|
+
# A relay only adds localhost hosts through its admin API, so a PDS cannot register
|
|
15
|
+
# itself with requestCrawl. relay-init adds each PDS once the relay is up.
|
|
16
|
+
|
|
17
|
+
x-relay-admin-password: &relay-admin-password ${SG_RELAY_ADMIN_PASSWORD:?snowglobe up makes it}
|
|
18
|
+
|
|
19
|
+
services:
|
|
20
|
+
pds:
|
|
21
|
+
ports: !override
|
|
22
|
+
- "${SG_SPACES_PDS_PORT:-${DEVNET_SPACES_PDS_PORT:-3010}}:${SG_SPACES_PDS_PORT:-${DEVNET_SPACES_PDS_PORT:-3010}}"
|
|
23
|
+
- "3020:3020"
|
|
24
|
+
- "3030:3030"
|
|
25
|
+
- "${SG_RELAY_PORT:-${DEVNET_RELAY_PORT:-2470}}:2470"
|
|
26
|
+
- "${SG_TAP_PORT:-${DEVNET_TAP_PORT:-2480}}:2480"
|
|
27
|
+
|
|
28
|
+
relay:
|
|
29
|
+
# Upstream's relay with one patch, so it can crawl PDSes on localhost; see
|
|
30
|
+
# relay/Dockerfile. SG_RELAY_INDIGO_COMMIT picks the indigo commit (it needs
|
|
31
|
+
# a published relay-<commit> image, and the patch has to apply).
|
|
32
|
+
build:
|
|
33
|
+
context: ./stack/relay
|
|
34
|
+
args:
|
|
35
|
+
INDIGO_COMMIT: ${SG_RELAY_INDIGO_COMMIT:-${DEVNET_RELAY_INDIGO_COMMIT:-b2619d864df00a66455bd9f5f0c83c1ac13ad73d}}
|
|
36
|
+
image: atproto-devnet-relay:${SG_RELAY_INDIGO_COMMIT:-${DEVNET_RELAY_INDIGO_COMMIT:-b2619d864df00a66455bd9f5f0c83c1ac13ad73d}}
|
|
37
|
+
restart: unless-stopped
|
|
38
|
+
network_mode: service:pds
|
|
39
|
+
depends_on:
|
|
40
|
+
pds:
|
|
41
|
+
condition: service_healthy
|
|
42
|
+
environment:
|
|
43
|
+
RELAY_ADMIN_PASSWORD: *relay-admin-password
|
|
44
|
+
RELAY_PLC_HOST: http://plc:2582
|
|
45
|
+
# ws:// hosts; localhost hosts are accepted only through the admin API
|
|
46
|
+
RELAY_ALLOW_INSECURE_HOSTS: "true"
|
|
47
|
+
# the patched host check (relay/allow-private-hosts.patch)
|
|
48
|
+
RELAY_ALLOW_PRIVATE_HOSTS: "true"
|
|
49
|
+
# the PDS builds differ in how much sync metadata their events carry
|
|
50
|
+
RELAY_LENIENT_SYNC_VALIDATION: "true"
|
|
51
|
+
ENVIRONMENT: dev
|
|
52
|
+
volumes:
|
|
53
|
+
- relay-data:/data/relay
|
|
54
|
+
healthcheck:
|
|
55
|
+
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:2470/xrpc/_health"]
|
|
56
|
+
interval: 5s
|
|
57
|
+
timeout: 3s
|
|
58
|
+
retries: 10
|
|
59
|
+
|
|
60
|
+
relay-init:
|
|
61
|
+
image: alpine:3.20
|
|
62
|
+
network_mode: service:pds
|
|
63
|
+
depends_on:
|
|
64
|
+
relay:
|
|
65
|
+
condition: service_healthy
|
|
66
|
+
pds-regular:
|
|
67
|
+
condition: service_healthy
|
|
68
|
+
pds-prod:
|
|
69
|
+
condition: service_healthy
|
|
70
|
+
environment:
|
|
71
|
+
RELAY_ADMIN_PASSWORD: *relay-admin-password
|
|
72
|
+
RELAY_HOSTS: localhost:${SG_SPACES_PDS_PORT:-${DEVNET_SPACES_PDS_PORT:-3010}} localhost:3020 localhost:3030
|
|
73
|
+
entrypoint: /bin/sh
|
|
74
|
+
command:
|
|
75
|
+
- -c
|
|
76
|
+
- |
|
|
77
|
+
set -eu
|
|
78
|
+
apk add --no-cache curl >/dev/null 2>&1
|
|
79
|
+
for host in $$RELAY_HOSTS; do
|
|
80
|
+
echo "[relay-init] crawl $$host"
|
|
81
|
+
curl -sf -u "admin:$$RELAY_ADMIN_PASSWORD" -H "Content-Type: application/json" \
|
|
82
|
+
-d "{\"hostname\": \"$$host\"}" http://localhost:2470/admin/pds/requestCrawl
|
|
83
|
+
echo
|
|
84
|
+
done
|
|
85
|
+
restart: "no"
|
|
86
|
+
|
|
87
|
+
jetstream:
|
|
88
|
+
depends_on:
|
|
89
|
+
relay:
|
|
90
|
+
condition: service_healthy
|
|
91
|
+
environment:
|
|
92
|
+
JETSTREAM_WS_URL: ws://pds:2470/xrpc/com.atproto.sync.subscribeRepos
|
|
93
|
+
|
|
94
|
+
tap:
|
|
95
|
+
network_mode: service:pds
|
|
96
|
+
ports: !reset []
|
|
97
|
+
networks: !reset []
|
|
98
|
+
depends_on:
|
|
99
|
+
relay:
|
|
100
|
+
condition: service_healthy
|
|
101
|
+
environment:
|
|
102
|
+
TAP_RELAY_URL: http://localhost:2470
|
|
103
|
+
|
|
104
|
+
volumes:
|
|
105
|
+
relay-data:
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Postgres and MailDev, which the PLC server and the PDS's email verification need. Read by
|
|
2
|
+
# lib/render.mjs; not meant to be run directly (see docker-compose.yml).
|
|
3
|
+
|
|
4
|
+
services:
|
|
5
|
+
postgres:
|
|
6
|
+
image: postgres:16-alpine
|
|
7
|
+
# PLC restarts on its own (unless-stopped) but needs this; without it a
|
|
8
|
+
# Docker restart leaves postgres down and PLC crash-looping on DNS.
|
|
9
|
+
restart: unless-stopped
|
|
10
|
+
environment:
|
|
11
|
+
POSTGRES_USER: ${SG_DB_USER:-${DEVNET_DB_USER:-postgres}}
|
|
12
|
+
POSTGRES_PASSWORD: ${SG_DB_PASSWORD:?snowglobe up makes it}
|
|
13
|
+
POSTGRES_DB: ${SG_DB_NAME:-${DEVNET_DB_NAME:-plc}}
|
|
14
|
+
ports:
|
|
15
|
+
- "${SG_POSTGRES_PORT:-${DEVNET_POSTGRES_PORT:-5433}}:5432"
|
|
16
|
+
networks:
|
|
17
|
+
- atproto-devnet
|
|
18
|
+
healthcheck:
|
|
19
|
+
test: ["CMD-SHELL", "pg_isready -U ${SG_DB_USER:-${DEVNET_DB_USER:-postgres}}"]
|
|
20
|
+
interval: 3s
|
|
21
|
+
timeout: 3s
|
|
22
|
+
retries: 10
|
|
23
|
+
|
|
24
|
+
maildev:
|
|
25
|
+
image: maildev/maildev:latest
|
|
26
|
+
restart: unless-stopped
|
|
27
|
+
ports:
|
|
28
|
+
- "${SG_MAILDEV_WEB_PORT:-${DEVNET_MAILDEV_WEB_PORT:-1081}}:1080"
|
|
29
|
+
- "${SG_MAILDEV_SMTP_PORT:-${DEVNET_MAILDEV_SMTP_PORT:-1026}}:1025"
|
|
30
|
+
networks:
|
|
31
|
+
- atproto-devnet
|
|
32
|
+
|
|
33
|
+
# Override PLC/PDS to depend on test postgres/maildev
|
|
34
|
+
plc:
|
|
35
|
+
depends_on:
|
|
36
|
+
postgres:
|
|
37
|
+
condition: service_healthy
|
|
38
|
+
|
|
39
|
+
pds:
|
|
40
|
+
environment:
|
|
41
|
+
PDS_EMAIL_SMTP_URL: smtp://maildev:1025
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# The service definitions snowglobe starts from (lib/render.mjs reads this file and
|
|
2
|
+
# docker-compose.test.yml and docker-compose.relay.yml, and writes the compose file a snowglobe
|
|
3
|
+
# runs). Not meant to be run directly: its paths are relative to the repository root, so use
|
|
4
|
+
# `bin/snowglobe.mjs up`.
|
|
5
|
+
#
|
|
6
|
+
# Needs from the rendered file: PostgreSQL (for the PLC server) and an SMTP server (for the
|
|
7
|
+
# PDS's email verification), which docker-compose.test.yml supplies.
|
|
8
|
+
|
|
9
|
+
services:
|
|
10
|
+
# ── DID Registry ─────────────────────────────────────────────────
|
|
11
|
+
plc:
|
|
12
|
+
image: itaru2622/bluesky-did-method-plc:latest
|
|
13
|
+
restart: unless-stopped
|
|
14
|
+
# PLC needs DB_CREDS_JSON as a JSON string, which can't be built from
|
|
15
|
+
# env var interpolation in compose. We wrap the entrypoint to construct
|
|
16
|
+
# it at runtime from the same credential vars used everywhere else. The container's own
|
|
17
|
+
# DEVNET_DB_* keep their names, though the settings are SG_DB_*: a project's overlay may set
|
|
18
|
+
# them on this service (openmeet-api's docker-compose-devnet.yml does).
|
|
19
|
+
entrypoint: >
|
|
20
|
+
sh -c "
|
|
21
|
+
export DB_CREDS_JSON=$$(printf '{\"username\":\"%s\",\"password\":\"%s\",\"host\":\"%s\",\"port\":\"%s\",\"database\":\"%s\"}' \"$$DEVNET_DB_USER\" \"$$DEVNET_DB_PASSWORD\" \"$$DEVNET_DB_HOST\" \"$$DEVNET_DB_PORT\" \"$$DEVNET_DB_NAME\")
|
|
22
|
+
export DB_MIGRATE_CREDS_JSON=$$DB_CREDS_JSON
|
|
23
|
+
exec node /app/packages/server/service/index.js
|
|
24
|
+
"
|
|
25
|
+
environment:
|
|
26
|
+
PORT: 2582
|
|
27
|
+
DEVNET_DB_USER: ${SG_DB_USER:-${DEVNET_DB_USER:-postgres}}
|
|
28
|
+
DEVNET_DB_PASSWORD: ${SG_DB_PASSWORD:?snowglobe up makes it}
|
|
29
|
+
DEVNET_DB_HOST: ${SG_DB_HOST:-${DEVNET_DB_HOST:-postgres}}
|
|
30
|
+
DEVNET_DB_PORT: ${SG_DB_PORT:-${DEVNET_DB_PORT:-5432}}
|
|
31
|
+
DEVNET_DB_NAME: ${SG_DB_NAME:-${DEVNET_DB_NAME:-plc}}
|
|
32
|
+
DATABASE_URL: "postgres://${SG_DB_USER:-${DEVNET_DB_USER:-postgres}}:${SG_DB_PASSWORD:?snowglobe up makes it}@${SG_DB_HOST:-${DEVNET_DB_HOST:-postgres}}:${SG_DB_PORT:-${DEVNET_DB_PORT:-5432}}/${SG_DB_NAME:-${DEVNET_DB_NAME:-plc}}"
|
|
33
|
+
ENABLE_MIGRATIONS: "true"
|
|
34
|
+
LOG_ENABLED: "true"
|
|
35
|
+
LOG_LEVEL: info
|
|
36
|
+
NODE_ENV: development
|
|
37
|
+
ports:
|
|
38
|
+
- "${SG_PLC_PORT:-${DEVNET_PLC_PORT:-2582}}:2582"
|
|
39
|
+
networks:
|
|
40
|
+
- atproto-devnet
|
|
41
|
+
healthcheck:
|
|
42
|
+
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:2582/_health"]
|
|
43
|
+
interval: 5s
|
|
44
|
+
timeout: 3s
|
|
45
|
+
retries: 10
|
|
46
|
+
|
|
47
|
+
# ── Personal Data Server ─────────────────────────────────────────
|
|
48
|
+
pds:
|
|
49
|
+
# Any PDS build: a release (ghcr.io/bluesky-social/pds:<version>) or an
|
|
50
|
+
# upstream commit (ghcr.io/bluesky-social/atproto:pds-<commit sha>).
|
|
51
|
+
image: ${SG_PDS_IMAGE:-${DEVNET_PDS_IMAGE:-ghcr.io/bluesky-social/pds:0.4}}
|
|
52
|
+
# Release images run as root; monorepo images run as `node`, which cannot
|
|
53
|
+
# open the root-owned pds-data volume.
|
|
54
|
+
user: root
|
|
55
|
+
restart: unless-stopped
|
|
56
|
+
depends_on:
|
|
57
|
+
plc:
|
|
58
|
+
condition: service_healthy
|
|
59
|
+
environment:
|
|
60
|
+
PDS_PORT: 3000
|
|
61
|
+
PDS_HOSTNAME: ${SG_PDS_HOSTNAME:-${DEVNET_PDS_HOSTNAME:-devnet.test}}
|
|
62
|
+
PDS_DATA_DIRECTORY: /pds/data
|
|
63
|
+
PDS_BLOBSTORE_DISK_LOCATION: /pds/data/blocks
|
|
64
|
+
PDS_DID_PLC_URL: http://plc:2582
|
|
65
|
+
# The PDS forwards app.bsky.* reads, and any XRPC method it does not implement
|
|
66
|
+
# (com.atproto.space.* on a build without spaces, for one), to this AppView with
|
|
67
|
+
# a service-auth token for the caller. The default host does not resolve, so
|
|
68
|
+
# nothing on this network reaches Bluesky's production AppView. Point it at a
|
|
69
|
+
# real AppView only if your app needs reads proxied through the PDS; Bluesky's
|
|
70
|
+
# AppView never sees devnet accounts, since no relay outside devnet crawls them.
|
|
71
|
+
PDS_BSKY_APP_VIEW_URL: ${SG_APPVIEW_URL:-${DEVNET_APPVIEW_URL:-https://appview.invalid}}
|
|
72
|
+
PDS_BSKY_APP_VIEW_DID: ${SG_APPVIEW_DID:-${DEVNET_APPVIEW_DID:-did:example:invalid}}
|
|
73
|
+
PDS_REPORT_SERVICE_URL: ${SG_REPORT_SERVICE_URL:-${DEVNET_REPORT_SERVICE_URL:-https://moderator.invalid}}
|
|
74
|
+
PDS_REPORT_SERVICE_DID: ${SG_REPORT_SERVICE_DID:-${DEVNET_REPORT_SERVICE_DID:-did:example:invalid}}
|
|
75
|
+
PDS_CRAWLERS: ""
|
|
76
|
+
# PDS builds after upstream 1ff43e6e5 (2026-09-10) resolve DIDs through the
|
|
77
|
+
# SSRF-protected fetch, which refuses the local PLC's http:// URL.
|
|
78
|
+
PDS_DISABLE_SSRF_PROTECTION: "true"
|
|
79
|
+
PDS_INVITE_REQUIRED: "true"
|
|
80
|
+
PDS_ADMIN_PASSWORD: ${SG_PDS_ADMIN_PASSWORD:?snowglobe up makes it}
|
|
81
|
+
PDS_JWT_SECRET: ${SG_PDS_JWT_SECRET:?snowglobe up makes it}
|
|
82
|
+
PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX: ${SG_PLC_ROTATION_KEY:?snowglobe up makes it}
|
|
83
|
+
PDS_EMAIL_SMTP_URL: ${SG_SMTP_URL:-${DEVNET_SMTP_URL:-smtp://maildev:1025}}
|
|
84
|
+
PDS_EMAIL_FROM_ADDRESS: noreply@devnet.test
|
|
85
|
+
PDS_HANDLE_DOMAINS: ${SG_HANDLE_DOMAIN:-${DEVNET_HANDLE_DOMAIN:-.devnet.test}}
|
|
86
|
+
PDS_SERVICE_HANDLE_DOMAINS: ${SG_HANDLE_DOMAIN:-${DEVNET_HANDLE_DOMAIN:-.devnet.test}}
|
|
87
|
+
LOG_ENABLED: "true"
|
|
88
|
+
NODE_ENV: development
|
|
89
|
+
volumes:
|
|
90
|
+
- pds-data:/pds/data
|
|
91
|
+
ports:
|
|
92
|
+
- "${SG_PDS_PORT:-${DEVNET_PDS_PORT:-3000}}:3000"
|
|
93
|
+
networks:
|
|
94
|
+
- atproto-devnet
|
|
95
|
+
healthcheck:
|
|
96
|
+
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:3000/xrpc/_health"]
|
|
97
|
+
interval: 5s
|
|
98
|
+
timeout: 3s
|
|
99
|
+
retries: 10
|
|
100
|
+
|
|
101
|
+
# ── Jetstream (JSON event stream from PDS firehose) ──────────────
|
|
102
|
+
jetstream:
|
|
103
|
+
image: ghcr.io/bluesky-social/jetstream:sha-05c89f0
|
|
104
|
+
restart: unless-stopped
|
|
105
|
+
depends_on:
|
|
106
|
+
pds:
|
|
107
|
+
condition: service_healthy
|
|
108
|
+
environment:
|
|
109
|
+
JETSTREAM_WS_URL: ws://pds:3000/xrpc/com.atproto.sync.subscribeRepos
|
|
110
|
+
JETSTREAM_DATA_DIR: /data
|
|
111
|
+
JETSTREAM_LIVENESS_TTL: 86400s
|
|
112
|
+
volumes:
|
|
113
|
+
- jetstream-data:/data
|
|
114
|
+
ports:
|
|
115
|
+
- "${SG_JETSTREAM_PORT:-${DEVNET_JETSTREAM_PORT:-6008}}:6008"
|
|
116
|
+
- "${SG_JETSTREAM_METRICS_PORT:-${DEVNET_JETSTREAM_METRICS_PORT:-6009}}:6009"
|
|
117
|
+
networks:
|
|
118
|
+
- atproto-devnet
|
|
119
|
+
healthcheck:
|
|
120
|
+
test: ["CMD-SHELL", "bash -c 'echo > /dev/tcp/127.0.0.1/6008'"]
|
|
121
|
+
interval: 5s
|
|
122
|
+
timeout: 3s
|
|
123
|
+
retries: 10
|
|
124
|
+
|
|
125
|
+
# ── TAP (repo sync + backfill) ───────────────────────────────────
|
|
126
|
+
tap:
|
|
127
|
+
# Pinned by digest (indigo revision 4f47add4, built 2026-03-11): the one the Statusphere flow was run against.
|
|
128
|
+
image: ghcr.io/bluesky-social/indigo/tap@sha256:5e20bfe416d29fcd215ed8bf99f10b2ab825a6de4e5599846dd33967ade2abeb
|
|
129
|
+
restart: unless-stopped
|
|
130
|
+
depends_on:
|
|
131
|
+
pds:
|
|
132
|
+
condition: service_healthy
|
|
133
|
+
environment:
|
|
134
|
+
TAP_RELAY_URL: http://pds:3000
|
|
135
|
+
TAP_PLC_URL: http://plc:2582
|
|
136
|
+
TAP_DATABASE_URL: sqlite:///data/tap.db
|
|
137
|
+
TAP_DISABLE_ACKS: "true"
|
|
138
|
+
TAP_LOG_LEVEL: info
|
|
139
|
+
volumes:
|
|
140
|
+
- tap-data:/data
|
|
141
|
+
ports:
|
|
142
|
+
- "${SG_TAP_PORT:-${DEVNET_TAP_PORT:-2480}}:2480"
|
|
143
|
+
networks:
|
|
144
|
+
- atproto-devnet
|
|
145
|
+
healthcheck:
|
|
146
|
+
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:2480/health"]
|
|
147
|
+
interval: 5s
|
|
148
|
+
timeout: 3s
|
|
149
|
+
retries: 10
|
|
150
|
+
|
|
151
|
+
# ── Init (one-shot setup) ────────────────────────────────────────
|
|
152
|
+
init:
|
|
153
|
+
image: alpine:3.20
|
|
154
|
+
depends_on:
|
|
155
|
+
pds:
|
|
156
|
+
condition: service_healthy
|
|
157
|
+
plc:
|
|
158
|
+
condition: service_healthy
|
|
159
|
+
volumes:
|
|
160
|
+
- ./stack/init:/scripts:ro
|
|
161
|
+
- ./data:/devnet-data
|
|
162
|
+
environment:
|
|
163
|
+
PDS_HOSTNAME: ${SG_PDS_HOSTNAME:-${DEVNET_PDS_HOSTNAME:-devnet.test}}
|
|
164
|
+
PDS_ADMIN_PASSWORD: ${SG_PDS_ADMIN_PASSWORD:?snowglobe up makes it}
|
|
165
|
+
HANDLE_DOMAIN: ${SG_HANDLE_DOMAIN:-${DEVNET_HANDLE_DOMAIN:-.devnet.test}}
|
|
166
|
+
SEED_ACCOUNTS: ${SG_SEED_ACCOUNTS:-${DEVNET_SEED_ACCOUNTS:-true}}
|
|
167
|
+
entrypoint: /bin/sh
|
|
168
|
+
command: ["/scripts/init.sh"]
|
|
169
|
+
networks:
|
|
170
|
+
- atproto-devnet
|
|
171
|
+
restart: "no"
|
|
172
|
+
|
|
173
|
+
volumes:
|
|
174
|
+
pds-data:
|
|
175
|
+
jetstream-data:
|
|
176
|
+
tap-data:
|
|
177
|
+
|
|
178
|
+
networks:
|
|
179
|
+
atproto-devnet:
|
|
@@ -0,0 +1,31 @@
|
|
|
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>: it fetches a confidential client's
|
|
3
|
+
// metadata and keys from there. Docker's DNS has no wildcard names, so every such name is
|
|
4
|
+
// looked up as nginx instead, which routes it by name (stack/https/nginx.conf.template). Only
|
|
5
|
+
// the address lookup changes: the request still carries the app's name, and TLS still
|
|
6
|
+
// checks the certificate for it against the snowglobe CA. Every other name resolves as usual.
|
|
7
|
+
//
|
|
8
|
+
// SG_RESOLVE_DOMAIN sets the domain whose names, itself included, go to nginx. Each PDS sets it
|
|
9
|
+
// to app.<domain>, where the apps are; a group host sets it to the globe's domain: it resolves its members' handles, which Docker's DNS does
|
|
10
|
+
// not know.
|
|
11
|
+
'use strict';
|
|
12
|
+
const dns = require('dns');
|
|
13
|
+
|
|
14
|
+
// DEVNET_ was these settings' prefix until 2026-10-20; a container made before then still has it.
|
|
15
|
+
const DOMAIN = (process.env.SG_RESOLVE_DOMAIN || process.env.DEVNET_RESOLVE_DOMAIN || '').toLowerCase();
|
|
16
|
+
const PROXY = process.env.SG_APP_PROXY_HOST || process.env.DEVNET_APP_PROXY_HOST || 'nginx';
|
|
17
|
+
const isApp = (name) => {
|
|
18
|
+
if (typeof name !== 'string') return false;
|
|
19
|
+
const n = name.toLowerCase().replace(/\.$/, '');
|
|
20
|
+
return DOMAIN !== '' && (n === DOMAIN || n.endsWith(`.${DOMAIN}`));
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const lookup = dns.lookup;
|
|
24
|
+
dns.lookup = function snowglobeLookup(hostname, ...rest) {
|
|
25
|
+
return lookup.call(this, isApp(hostname) ? PROXY : hostname, ...rest);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const lookupPromise = dns.promises.lookup;
|
|
29
|
+
dns.promises.lookup = function snowglobeLookupPromise(hostname, ...rest) {
|
|
30
|
+
return lookupPromise.call(this, isApp(hostname) ? PROXY : hostname, ...rest);
|
|
31
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# The DNS a service in the compose network needs to check a handle: any name under
|
|
2
|
+
# SG_DOMAIN answers nginx's address. Docker's own DNS answers the service names first
|
|
3
|
+
# and asks this only for names it does not know; this refuses every name outside the domain,
|
|
4
|
+
# so nothing on the public internet resolves.
|
|
5
|
+
# On the machine the lab does this one name at a time (bin/snowglobe.mjs run); inside the
|
|
6
|
+
# network there is no such thing, and a handle's name is made when its account is.
|
|
7
|
+
FROM alpine:3.22
|
|
8
|
+
RUN apk add --no-cache dnsmasq
|
|
9
|
+
COPY dns.sh /dns.sh
|
|
10
|
+
ENTRYPOINT ["/bin/sh", "/dns.sh"]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# dnsmasq for the https snowglobe. Every name under $SG_DOMAIN answers nginx's address
|
|
3
|
+
# (A only; other record types get no answer, and nothing under the domain is asked of any
|
|
4
|
+
# other server). It is the container's `dns:` server, which Docker's embedded DNS asks for
|
|
5
|
+
# any name it does not know itself, so service names keep working and nothing else is
|
|
6
|
+
# answered here.
|
|
7
|
+
# nginx's address changes when nginx is recreated, so it is looked up again every few
|
|
8
|
+
# seconds and dnsmasq restarted when it moved.
|
|
9
|
+
# When dnsmasq dies it is started again here, and this script does not exit: slingshot runs
|
|
10
|
+
# in this container's network namespace, and restarting this container leaves slingshot
|
|
11
|
+
# with no network at all until slingshot is recreated too.
|
|
12
|
+
set -eu
|
|
13
|
+
: "${SG_DOMAIN:?}"
|
|
14
|
+
ip_of_nginx() { getent hosts nginx | awk 'NR == 1 { print $1 }'; }
|
|
15
|
+
start() {
|
|
16
|
+
dnsmasq -k --no-resolv --no-hosts \
|
|
17
|
+
--local="/${SG_DOMAIN}/" --address="/${SG_DOMAIN}/$1" &
|
|
18
|
+
pid=$!
|
|
19
|
+
}
|
|
20
|
+
trap 'kill "${pid:-0}" 2>/dev/null; exit 0' TERM INT
|
|
21
|
+
# With no address, --address would answer NXDOMAIN for the whole domain, so wait for one.
|
|
22
|
+
ip=""
|
|
23
|
+
until [ -n "${ip}" ]; do
|
|
24
|
+
ip=$(ip_of_nginx || true)
|
|
25
|
+
[ -n "${ip}" ] || sleep 1
|
|
26
|
+
done
|
|
27
|
+
start "${ip}"
|
|
28
|
+
while sleep 5; do
|
|
29
|
+
now=$(ip_of_nginx || true)
|
|
30
|
+
if [ -n "${now}" ] && [ "${now}" != "${ip}" ]; then
|
|
31
|
+
kill "${pid}" 2>/dev/null || true
|
|
32
|
+
wait "${pid}" 2>/dev/null || true
|
|
33
|
+
ip="${now}"
|
|
34
|
+
start "${ip}"
|
|
35
|
+
elif ! kill -0 "${pid}" 2>/dev/null; then
|
|
36
|
+
echo "dns.sh: dnsmasq exited; starting it again" >&2
|
|
37
|
+
wait "${pid}" 2>/dev/null || true
|
|
38
|
+
start "${ip}"
|
|
39
|
+
fi
|
|
40
|
+
done
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# nginx for the https snowglobe (the globe's compose.yml, bin/snowglobe.mjs render). One leaf
|
|
2
|
+
# certificate names every host below; data/https/ca.crt is the CA that signed it (bin/snowglobe.mjs certs).
|
|
3
|
+
# A template: at start the image writes it to conf.d/default.conf with the SG_DOMAIN
|
|
4
|
+
# setting filled in, so this is the http block's whole server list. The comments below
|
|
5
|
+
# name its default, local.snowglobe.at.
|
|
6
|
+
|
|
7
|
+
# Docker's embedded DNS, re-asked every 10 s, so a recreated container's new
|
|
8
|
+
# address is picked up without reloading nginx.
|
|
9
|
+
resolver 127.0.0.11 valid=10s ipv6=off;
|
|
10
|
+
|
|
11
|
+
map $http_upgrade $connection_upgrade {
|
|
12
|
+
default upgrade;
|
|
13
|
+
'' close;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
ssl_certificate /etc/nginx/https/leaf.crt;
|
|
17
|
+
ssl_certificate_key /etc/nginx/https/leaf.key;
|
|
18
|
+
ssl_protocols TLSv1.2 TLSv1.3;
|
|
19
|
+
|
|
20
|
+
proxy_http_version 1.1;
|
|
21
|
+
proxy_set_header Host $host;
|
|
22
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
23
|
+
proxy_set_header X-Forwarded-Proto https;
|
|
24
|
+
proxy_set_header Upgrade $http_upgrade;
|
|
25
|
+
proxy_set_header Connection $connection_upgrade;
|
|
26
|
+
# Firehose and event-stream websockets stay open.
|
|
27
|
+
proxy_read_timeout 1h;
|
|
28
|
+
proxy_send_timeout 1h;
|
|
29
|
+
# Blob uploads.
|
|
30
|
+
client_max_body_size 100m;
|
|
31
|
+
|
|
32
|
+
# A name this file does not list gets no certificate at all.
|
|
33
|
+
server {
|
|
34
|
+
listen 443 ssl default_server;
|
|
35
|
+
ssl_reject_handshake on;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
# Each PDS and its handles, and the relay, the feed, Slingshot and PDSls, at their names under
|
|
39
|
+
# the domain: rendered from snowglobe.yml by bin/snowglobe.mjs render into the globe's nginx/
|
|
40
|
+
# folder (data/globe/nginx/globe.conf), mounted at /etc/nginx/globe/. Each PDS is its own
|
|
41
|
+
# service, pds-<name>:3000. A PDS answers /.well-known/atproto-did by Host, which the http block
|
|
42
|
+
# passes through as sent. An app's handle ending (below) wins over a PDS's *.<domain> as the
|
|
43
|
+
# longer wildcard.
|
|
44
|
+
include /etc/nginx/globe/*.conf;
|
|
45
|
+
|
|
46
|
+
# The directory at an https name under the domain, as a hosted globe has it (plc.<globe>), so an
|
|
47
|
+
# app's browser code, which cannot use plain http or the host's ports, reaches it too.
|
|
48
|
+
server {
|
|
49
|
+
listen 443 ssl;
|
|
50
|
+
server_name plc.${SG_DOMAIN};
|
|
51
|
+
location / {
|
|
52
|
+
set $plc http://plc:2582;
|
|
53
|
+
proxy_pass $plc;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
# The network's own page at the bare name, as a globe serves it at <globe>.snowglobe.at: the same
|
|
58
|
+
# page/index.html, with this stack's relays (page/snowglobe.conf) and a snowglobe.json naming its
|
|
59
|
+
# services, which bin/snowglobe.mjs up and snowglobe app write.
|
|
60
|
+
server {
|
|
61
|
+
listen 443 ssl;
|
|
62
|
+
server_name ${SG_DOMAIN};
|
|
63
|
+
server_tokens off;
|
|
64
|
+
root /srv/page;
|
|
65
|
+
include /etc/nginx/page/snowglobe.conf;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
# This stack's PLC, standing in for the real directory.
|
|
69
|
+
server {
|
|
70
|
+
listen 443 ssl;
|
|
71
|
+
server_name plc.directory;
|
|
72
|
+
location / {
|
|
73
|
+
set $plc http://plc:2582;
|
|
74
|
+
proxy_pass $plc;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
# Apps on this machine: https://<name>.app.${SG_DOMAIN} goes to the port that
|
|
79
|
+
# bin/snowglobe.mjs app recorded for <name> (data/https/apps). It writes, into
|
|
80
|
+
# data/https/nginx/ (mounted at /etc/nginx/snowglobe/), one upstream per app, naming
|
|
81
|
+
# host.docker.internal, and one map line per name. An upstream's name is resolved
|
|
82
|
+
# once, when nginx loads it, through the container's hosts file (extra_hosts), which
|
|
83
|
+
# Docker's DNS does not serve. Neither file has to exist. An app that claimed a handle
|
|
84
|
+
# ending (bin/snowglobe.mjs app <name> <port> <ending>) also gets a server there for
|
|
85
|
+
# *.<ending>.local.snowglobe.at, which wins over a PDS's *.local.snowglobe.at as the
|
|
86
|
+
# longer wildcard.
|
|
87
|
+
include /etc/nginx/snowglobe/*.conf;
|
|
88
|
+
|
|
89
|
+
map $host $snowglobe_app {
|
|
90
|
+
default "";
|
|
91
|
+
include /etc/nginx/snowglobe/*.map;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
# A name no app registered gets a 404 from nginx itself and reaches nothing.
|
|
95
|
+
server {
|
|
96
|
+
listen 443 ssl;
|
|
97
|
+
server_name *.app.${SG_DOMAIN};
|
|
98
|
+
if ($snowglobe_app = "") {
|
|
99
|
+
return 404 "No app is registered at $host (bin/snowglobe.mjs app).\n";
|
|
100
|
+
}
|
|
101
|
+
location / {
|
|
102
|
+
proxy_pass http://$snowglobe_app;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# The snowglobe's way in for a browser on another computer (server.mjs): a proxy to nginx and
|
|
2
|
+
# the PAC file that points at it, on one port. No dependencies, Node's own net and http.
|
|
3
|
+
FROM node:22-alpine
|
|
4
|
+
WORKDIR /app
|
|
5
|
+
COPY server.mjs ./
|
|
6
|
+
USER node
|
|
7
|
+
EXPOSE 1080
|
|
8
|
+
CMD ["node", "server.mjs"]
|