Monorepo for Tangled tangled.org
8

Configure Feed

Select the types of activity you want to include in your feed.

localinfra: sandboxed docker dev env

sandboxing all microservices required to run Tangled including entire
atproto infra in docker-compose.

atproto infra:
- did-method-plc (NOTE: linux/amd64 only)
- pds
- jetstream (NOTE: linux/amd64 only)

tangled services:
- knot
- knotmirror
- knotmirror-tap
- appview (air hot reloaded)
- tailwind-watch

misc:
- redis
- postgres
- caddy (reverse proxy)

spindle is not included in this revision to make things simple. It needs
some patches on appview mostly related to TLS handling in dev mode.

Signed-off-by: Seongmin Lee <git@boltless.me>

author
Seongmin Lee
committer
Tangled
date (May 24, 2026, 7:41 PM +0300) commit c2f17d5e parent ffc41510 change-id prlpqwou
+846
+38
.dockerignore
··· 1 + .git 2 + .DS_Store 3 + ._.DS_Store 4 + .direnv/ 5 + .env* 6 + *.qcow2 7 + genjwks.out 8 + /nix/vm-data 9 + blog/build 10 + build/ 11 + .wrangler/ 12 + localinfra/certs/root.key 13 + 14 + appview/pages/static/* 15 + !appview/pages/static/topbar-search.js 16 + 17 + sites/target 18 + sites/.wrangler 19 + 20 + result 21 + result-* 22 + build 23 + .bin 24 + out/ 25 + node_modules/ 26 + *.db 27 + *.db-* 28 + *.rdb 29 + *.bleve 30 + **/*.bleve 31 + 32 + tmp 33 + patches 34 + 35 + docker-compose.yml 36 + docker-compose.*.yml 37 + **/Dockerfile 38 + **/*.Dockerfile
+1
.gitignore
··· 25 25 blog/build/ 26 26 build/ 27 27 .wrangler/ 28 + localinfra/certs/*
+283
docker-compose.yml
··· 1 + services: 2 + redis: 3 + image: redis:7-alpine 4 + restart: unless-stopped 5 + networks: [tngl] 6 + 7 + postgres: 8 + image: postgres:14-alpine 9 + restart: unless-stopped 10 + environment: 11 + POSTGRES_USER: tnglr 12 + POSTGRES_PASSWORD: tnglr 13 + volumes: 14 + - postgres-data:/var/lib/postgresql/data 15 + - ./localinfra/postgres-init.sql:/docker-entrypoint-initdb.d/init.sql 16 + healthcheck: 17 + test: ["CMD-SHELL", "pg_isready -U tnglr -d plc"] 18 + interval: 2s 19 + timeout: 2s 20 + retries: 30 21 + start_period: 5s 22 + networks: [tngl] 23 + 24 + pds: 25 + image: ghcr.io/bluesky-social/pds:0.4.219 26 + restart: unless-stopped 27 + env_file: localinfra/pds.env 28 + environment: 29 + PDS_DID_PLC_URL: http://plc:8080 30 + NODE_EXTRA_CA_CERTS: /caddy-ca/root.crt 31 + volumes: 32 + - pds-data:/pds 33 + - ./localinfra/certs/root.crt:/caddy-ca/root.crt:ro 34 + healthcheck: 35 + test: ["CMD", "wget", "-qO-", "http://localhost:3000/xrpc/_health"] 36 + interval: 2s 37 + timeout: 2s 38 + retries: 30 39 + start_period: 5s 40 + depends_on: 41 + plc: 42 + condition: service_started 43 + networks: [tngl] 44 + 45 + init-accounts: 46 + image: alpine:3.22 47 + restart: "no" 48 + env_file: localinfra/pds.env 49 + environment: 50 + PDS_URL: http://pds:3000 51 + OWNER_USER: alice 52 + KNOT_HOSTNAME: knot.tngl.boltless.dev 53 + volumes: 54 + - ./localinfra/scripts/init-accounts.sh:/init.sh:ro 55 + - init-state:/shared 56 + command: sh -c "apk add --no-cache curl jq >/dev/null && sh /init.sh" 57 + depends_on: 58 + pds: 59 + condition: service_healthy 60 + networks: [tngl] 61 + 62 + plc: 63 + platform: linux/amd64 64 + image: ghcr.io/bluesky-social/did-method-plc:plc-f2ab7516bac5bc0f3f86842fa94e996bd1b3815b 65 + restart: unless-stopped 66 + environment: 67 + DEBUG_MODE: "1" 68 + LOG_ENABLED: "true" 69 + LOG_LEVEL: debug 70 + LOG_DESTINATION: "1" 71 + PLC_VERSION: 0.0.1 72 + PORT: "8080" 73 + DB_CREDS_JSON: &DB_CREDS_JSON '{"username":"tnglr","password":"tnglr","host":"postgres","port":5432}' 74 + DB_MIGRATE_CREDS_JSON: *DB_CREDS_JSON 75 + depends_on: [postgres] 76 + networks: [tngl] 77 + 78 + jetstream: 79 + platform: linux/amd64 80 + image: ghcr.io/bluesky-social/jetstream:sha-d5a3b62 81 + restart: unless-stopped 82 + environment: 83 + JETSTREAM_DATA_DIR: /data 84 + JETSTREAM_LIVENESS_TTL: 300s 85 + JETSTREAM_WS_URL: wss://pds.tngl.boltless.dev/xrpc/com.atproto.sync.subscribeRepos 86 + volumes: 87 + - jetstream-data:/data 88 + - ./localinfra/certs/root.crt:/etc/ssl/certs/caddy.crt:ro 89 + depends_on: 90 + pds: 91 + condition: service_healthy 92 + networks: [tngl] 93 + 94 + knot: 95 + build: 96 + context: . 97 + dockerfile: localinfra/knot.Dockerfile 98 + restart: unless-stopped 99 + environment: 100 + KNOT_SERVER_HOSTNAME: knot.tngl.boltless.dev 101 + KNOT_SERVER_LISTEN_ADDR: 0.0.0.0:5555 102 + KNOT_SERVER_INTERNAL_LISTEN_ADDR: 127.0.0.1:5444 103 + KNOT_SERVER_DB_PATH: /home/git/knotserver.db 104 + KNOT_SERVER_PLC_URL: https://plc.tngl.boltless.dev 105 + KNOT_SERVER_JETSTREAM_ENDPOINT: wss://jetstream.tngl.boltless.dev/subscribe 106 + KNOT_SERVER_DEV: "false" 107 + KNOT_REPO_SCAN_PATH: /home/git/repositories 108 + APPVIEW_ENDPOINT: https://tangled.org 109 + KNOT_MIRRORS: https://mirror.tngl.boltless.dev 110 + ports: 111 + - "2222:22" 112 + volumes: 113 + - knot-data:/home/git 114 + - knot-ssh-keys:/etc/ssh/keys 115 + - init-state:/shared:ro 116 + - ./localinfra/certs/root.crt:/etc/ssl/certs/caddy.crt:ro 117 + healthcheck: 118 + test: ["CMD", "wget", "-qO-", "http://localhost:5555/"] 119 + interval: 2s 120 + timeout: 2s 121 + retries: 60 122 + start_period: 30s 123 + depends_on: 124 + plc: 125 + condition: service_started 126 + jetstream: 127 + condition: service_started 128 + knotmirror: 129 + condition: service_healthy 130 + init-accounts: 131 + condition: service_completed_successfully 132 + networks: [tngl] 133 + 134 + knotmirror-tap: 135 + image: ghcr.io/bluesky-social/indigo/tap:sha-4f47add43060c27e8a37d9d76482ecddf001fcd8 # 0.1.10 136 + restart: unless-stopped 137 + environment: 138 + TAP_BIND: ":2480" 139 + TAP_PLC_URL: https://plc.tngl.boltless.dev 140 + TAP_RELAY_URL: https://pds.tngl.boltless.dev # PDS can be used as basic relay without collectiondir. 141 + TAP_DATABASE_URL: postgres://tnglr:tnglr@postgres:5432/mirror_tap?sslmode=disable 142 + TAP_COLLECTION_FILTERS: sh.tangled.repo 143 + TAP_SIGNAL_COLLECTION: sh.tangled.repo 144 + TAP_RESYNC_PARALLELISM: "10" 145 + TAP_RETRY_TIMEOUT: 60s 146 + volumes: 147 + - ./localinfra/certs/root.crt:/etc/ssl/certs/caddy.crt:ro 148 + depends_on: 149 + postgres: 150 + condition: service_started 151 + pds: 152 + condition: service_healthy 153 + networks: [tngl] 154 + 155 + knotmirror: 156 + build: 157 + context: . 158 + dockerfile: localinfra/knotmirror.Dockerfile 159 + restart: unless-stopped 160 + environment: 161 + MIRROR_LISTEN: 0.0.0.0:7000 162 + MIRROR_ADMIN_LISTEN: 0.0.0.0:7200 163 + MIRROR_HOSTNAME: mirror.tngl.boltless.dev 164 + MIRROR_TAP_URL: http://knotmirror-tap:2480 165 + MIRROR_DB_URL: postgres://tnglr:tnglr@postgres:5432/mirror?sslmode=disable 166 + MIRROR_REDIS_ADDR: redis:6379 167 + MIRROR_PLC_URL: https://plc.tngl.boltless.dev 168 + MIRROR_GIT_BASEPATH: /data/repos 169 + MIRROR_KNOT_USE_SSL: "true" 170 + MIRROR_KNOT_SSRF: "true" 171 + MIRROR_RESYNC_PARALLELISM: "4" 172 + volumes: 173 + - knotmirror-data:/data 174 + - ./localinfra/certs/root.crt:/etc/ssl/certs/caddy.crt:ro 175 + healthcheck: 176 + test: ["CMD", "wget", "-qO-", "http://localhost:7000/"] 177 + interval: 2s 178 + timeout: 2s 179 + retries: 30 180 + start_period: 5s 181 + ports: 182 + - "7201:7200" 183 + depends_on: 184 + postgres: 185 + condition: service_started 186 + knotmirror-tap: 187 + condition: service_started 188 + networks: [tngl] 189 + 190 + tailwind: 191 + image: d3fk/tailwindcss:v3 192 + restart: unless-stopped 193 + working_dir: /build 194 + init: true 195 + environment: 196 + BROWSERSLIST_IGNORE_OLD_DATA: "true" 197 + volumes: 198 + - ./tailwind.config.js:/build/tailwind.config.js:ro 199 + - ./input.css:/build/input.css:ro 200 + - ./appview/pages/templates:/build/appview/pages/templates:ro 201 + - ./docs:/build/docs:ro 202 + - ./blog/templates:/build/blog/templates:ro 203 + - ./blog/posts:/build/blog/posts:ro 204 + - ./appview/pages/static:/build/appview/pages/static 205 + command: ["-i", "input.css", "-o", "appview/pages/static/tw.css", "--watch=always"] 206 + network_mode: none 207 + 208 + appview: 209 + build: 210 + context: . 211 + dockerfile: localinfra/appview.Dockerfile 212 + restart: unless-stopped 213 + environment: 214 + TANGLED_DEV: "true" 215 + TANGLED_APPVIEW_HOST: 127.0.0.1:3000 216 + TANGLED_DB_PATH: /var/lib/appview/appview.db 217 + TANGLED_PLC_URL: https://plc.tngl.boltless.dev 218 + TANGLED_JETSTREAM_ENDPOINT: wss://jetstream.tngl.boltless.dev/subscribe 219 + TANGLED_REDIS_ADDR: redis:6379 220 + TANGLED_KNOTMIRROR_URL: https://mirror.tngl.boltless.dev 221 + ports: 222 + - "3000:3000" 223 + volumes: 224 + - .:/src:cached 225 + - go-cache:/go/cache 226 + - go-mod-cache:/go/mod 227 + - appview-data:/var/lib/appview 228 + - init-state:/shared:ro 229 + - ./localinfra/certs/root.crt:/etc/ssl/certs/caddy.crt:ro 230 + depends_on: 231 + redis: 232 + condition: service_started 233 + pds: 234 + condition: service_healthy 235 + init-accounts: 236 + condition: service_completed_successfully 237 + networks: [tngl] 238 + 239 + caddy: 240 + image: caddy:2-alpine 241 + restart: unless-stopped 242 + ports: 243 + - "80:80" 244 + - "443:443" 245 + volumes: 246 + - ./localinfra/Caddyfile:/etc/caddy/Caddyfile 247 + - ./localinfra/certs:/etc/caddy/certs:ro 248 + - caddy-data:/data 249 + networks: 250 + tngl: 251 + aliases: 252 + - plc.tngl.boltless.dev 253 + - pds.tngl.boltless.dev 254 + - alice.pds.tngl.boltless.dev 255 + - bob.pds.tngl.boltless.dev 256 + - jetstream.tngl.boltless.dev 257 + - knot.tngl.boltless.dev 258 + - spindle.tngl.boltless.dev 259 + - tngl.boltless.dev 260 + - mirror.tngl.boltless.dev 261 + 262 + volumes: 263 + caddy-data: 264 + postgres-data: 265 + pds-data: 266 + jetstream-data: 267 + knot-data: 268 + knot-ssh-keys: 269 + knotmirror-data: 270 + init-state: 271 + go-cache: 272 + go-mod-cache: 273 + appview-data: 274 + 275 + networks: 276 + tngl: 277 + driver: bridge 278 + # Public-looking subnet so SSRF checks see container IPs as "public". 279 + # RFC1918 + doc/benchmark ranges are blocklisted; 11.x is unrouted on 280 + # the public internet, so it passes the check and won't collide. 281 + ipam: 282 + config: 283 + - subnet: 11.0.0.0/24
+50
localinfra/Caddyfile
··· 1 + { 2 + # use pinned CA from ./localinfra/certs 3 + pki { 4 + ca local { 5 + name "Tangled Dev" 6 + root_cn "Tangled Dev Root" 7 + root { 8 + format pem_file 9 + cert /etc/caddy/certs/root.crt 10 + key /etc/caddy/certs/root.key 11 + } 12 + } 13 + } 14 + } 15 + 16 + # did-method-plc 17 + plc.tngl.boltless.dev { 18 + tls internal 19 + reverse_proxy plc:8080 20 + } 21 + 22 + # pds 23 + *.pds.tngl.boltless.dev, pds.tngl.boltless.dev { 24 + tls internal 25 + reverse_proxy pds:3000 26 + } 27 + 28 + # jetstream 29 + jetstream.tngl.boltless.dev { 30 + tls internal 31 + reverse_proxy jetstream:6008 32 + } 33 + 34 + # knot 35 + knot.tngl.boltless.dev { 36 + tls internal 37 + reverse_proxy knot:5555 38 + } 39 + 40 + # knotmirror 41 + mirror.tngl.boltless.dev { 42 + tls internal 43 + reverse_proxy knotmirror:7000 44 + } 45 + 46 + # appview 47 + tngl.boltless.dev { 48 + tls internal 49 + reverse_proxy appview:3000 50 + }
+53
localinfra/appview.Dockerfile
··· 1 + # Development only. Not for production use. 2 + 3 + FROM golang:1.25-alpine 4 + 5 + RUN apk add --no-cache git build-base sqlite-dev tini sqlite-libs ca-certificates 6 + 7 + # air for live-reload 8 + RUN go install github.com/air-verse/air@v1.65.1 && \ 9 + mv /go/bin/air /usr/local/bin/air 10 + 11 + # goat for generating OAuth client key (moved out of indigo) 12 + RUN go install github.com/bluesky-social/goat@v0.2.3 && \ 13 + mv /go/bin/goat /usr/local/bin/goat 14 + 15 + ENV CGO_ENABLED=1 16 + ENV GOCACHE=/go/cache 17 + ENV GOMODCACHE=/go/mod 18 + 19 + # Generates OAuth client key on first run. Persists to appview-data so re-runs 20 + # reuse the same key. Mirrors flake.nix:221-222. 21 + COPY <<'EOF' /usr/local/bin/appview-entrypoint.sh 22 + #!/bin/sh 23 + set -eu 24 + 25 + SECRET=/var/lib/appview/oauth-secret 26 + KID=/var/lib/appview/oauth-kid 27 + 28 + if [ ! -s "$SECRET" ]; then 29 + mkdir -p /var/lib/appview 30 + goat key generate -t P-256 \ 31 + | grep -A1 'Secret Key' | tail -n1 | awk '{print $1}' \ 32 + > "$SECRET" 33 + date +%s > "$KID" 34 + echo "[oauth] generated kid=$(cat $KID)" 35 + fi 36 + 37 + export TANGLED_OAUTH_CLIENT_SECRET="$(cat $SECRET)" 38 + export TANGLED_OAUTH_CLIENT_KID="$(cat $KID)" 39 + 40 + # Pulled in from init-accounts via /shared (mounted ro). 41 + [ -r /shared/label-defaults ] && export TANGLED_LABEL_DEFAULTS="$(cat /shared/label-defaults)" 42 + [ -r /shared/label-gfi ] && export TANGLED_LABEL_GFI="$(cat /shared/label-gfi)" 43 + 44 + exec air -c /src/.air/appview.toml 45 + EOF 46 + RUN chmod +x /usr/local/bin/appview-entrypoint.sh 47 + 48 + WORKDIR /src 49 + 50 + EXPOSE 3000 51 + 52 + ENTRYPOINT ["/sbin/tini", "--"] 53 + CMD ["/usr/local/bin/appview-entrypoint.sh"]
+96
localinfra/knot.Dockerfile
··· 1 + # Development only. Not for production use. 2 + 3 + FROM golang:1.25-alpine AS builder 4 + 5 + RUN apk add --no-cache git build-base sqlite-dev 6 + 7 + ENV CGO_ENABLED=1 8 + ENV GOCACHE=/go/cache 9 + ENV GOMODCACHE=/go/mod 10 + 11 + WORKDIR /src 12 + 13 + COPY go.mod go.sum ./ 14 + RUN --mount=type=cache,target=/go/cache \ 15 + --mount=type=cache,target=/go/mod \ 16 + go mod download 17 + 18 + COPY . . 19 + RUN --mount=type=cache,target=/go/cache \ 20 + --mount=type=cache,target=/go/mod \ 21 + go build -tags libsqlite3 -o /out/knot ./cmd/knot 22 + 23 + FROM alpine:3.20 24 + 25 + RUN apk add --no-cache git openssh-server tini sqlite-libs su-exec ca-certificates shadow openssl bash 26 + 27 + RUN groupadd -g 1000 -f git && \ 28 + useradd -u 1000 -g 1000 -d /home/git -s /bin/sh -m git && \ 29 + echo "git:$(openssl rand -hex 16)" | chpasswd 30 + 31 + COPY --from=builder /out/knot /usr/local/bin/knot 32 + RUN chmod 0755 /usr/local/bin/knot 33 + 34 + COPY <<'EOF' /usr/local/bin/knot-keys-wrapper 35 + #!/bin/sh 36 + exec /usr/local/bin/knot keys -output authorized-keys \ 37 + -internal-api "http://${KNOT_SERVER_INTERNAL_LISTEN_ADDR:-127.0.0.1:5444}" \ 38 + -git-dir "${KNOT_REPO_SCAN_PATH:-/home/git/repositories}" \ 39 + -log-path "/tmp/knotguard.log" 40 + EOF 41 + RUN chmod +x /usr/local/bin/knot-keys-wrapper 42 + 43 + # sshd config 44 + COPY <<'EOF' /etc/ssh/sshd_config.d/knot.conf 45 + PermitRootLogin no 46 + PasswordAuthentication no 47 + ChallengeResponseAuthentication no 48 + 49 + Match User git 50 + AuthorizedKeysCommand /usr/local/bin/knot-keys-wrapper 51 + AuthorizedKeysCommandUser nobody 52 + EOF 53 + 54 + RUN echo 'Include /etc/ssh/sshd_config.d/*.conf' >> /etc/ssh/sshd_config 55 + 56 + COPY <<'EOF' /etc/ssh/sshd_config.d/host-keys.conf 57 + HostKey /etc/ssh/keys/ssh_host_rsa_key 58 + HostKey /etc/ssh/keys/ssh_host_ecdsa_key 59 + HostKey /etc/ssh/keys/ssh_host_ed25519_key 60 + EOF 61 + 62 + RUN mkdir -p /home/git/.config/git 63 + COPY <<'EOF' /home/git/.config/git/config 64 + [user] 65 + name = Tangled 66 + email = noreply@tangled.org 67 + [receive] 68 + advertisePushOptions = true 69 + [uploadpack] 70 + allowFilter = true 71 + allowReachableSHA1InWant = true 72 + EOF 73 + RUN mkdir -p /home/git/repositories && chown -R git:git /home/git 74 + 75 + COPY <<'EOF' /usr/local/bin/knot-entrypoint.sh 76 + #!/bin/sh 77 + set -eu 78 + [ -z "${KNOT_SERVER_OWNER:-}" ] && [ -r /shared/owner-did ] && \ 79 + export KNOT_SERVER_OWNER="$(cat /shared/owner-did)" 80 + : "${KNOT_SERVER_OWNER:?set via env or /shared/owner-did}" 81 + 82 + mkdir -p /etc/ssh/keys 83 + [ -f /etc/ssh/keys/ssh_host_rsa_key ] || ssh-keygen -t rsa -f /etc/ssh/keys/ssh_host_rsa_key -q -N "" 84 + [ -f /etc/ssh/keys/ssh_host_ecdsa_key ] || ssh-keygen -t ecdsa -f /etc/ssh/keys/ssh_host_ecdsa_key -q -N "" 85 + [ -f /etc/ssh/keys/ssh_host_ed25519_key ] || ssh-keygen -t ed25519 -f /etc/ssh/keys/ssh_host_ed25519_key -q -N "" 86 + 87 + /usr/sbin/sshd -D -e & 88 + exec su-exec git /usr/local/bin/knot server 89 + EOF 90 + RUN chmod +x /usr/local/bin/knot-entrypoint.sh 91 + 92 + VOLUME /home/git 93 + EXPOSE 22 5555 94 + 95 + ENTRYPOINT ["/sbin/tini", "--"] 96 + CMD ["/usr/local/bin/knot-entrypoint.sh"]
+26
localinfra/knotmirror.Dockerfile
··· 1 + # Development only. Not for production use. 2 + 3 + FROM golang:1.25-alpine AS build 4 + 5 + RUN apk add --no-cache git 6 + 7 + WORKDIR /src 8 + COPY go.mod go.sum ./ 9 + RUN go mod download 10 + COPY . . 11 + RUN CGO_ENABLED=0 go build -o /knotmirror ./cmd/knotmirror 12 + 13 + FROM alpine:3.22 14 + 15 + RUN apk add --no-cache git tini ca-certificates 16 + 17 + # Trust dev CA in the system bundle so git/curl/openssl all accept caddy certs. 18 + COPY localinfra/certs/root.crt /usr/local/share/ca-certificates/caddy.crt 19 + RUN update-ca-certificates 20 + 21 + COPY --from=build /knotmirror /usr/local/bin/knotmirror 22 + 23 + EXPOSE 7000 24 + 25 + ENTRYPOINT ["/sbin/tini", "--"] 26 + CMD ["/usr/local/bin/knotmirror", "serve"]
+13
localinfra/pds.env
··· 1 + LOG_ENABLED=true 2 + 3 + PDS_JWT_SECRET=8cae8bffcc73d9932819650791e4e89a 4 + PDS_ADMIN_PASSWORD=d6a902588cd93bee1af83f924f60cfd3 5 + PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX=2e92e336a50a618458e1097d94a1db86ec3fd8829d7735020cbae80625c761d7 6 + 7 + PDS_DATA_DIRECTORY=/pds 8 + PDS_BLOBSTORE_DISK_LOCATION=/pds/blocks 9 + 10 + PDS_DID_PLC_URL=http://localhost:8080 11 + PDS_HOSTNAME=pds.tngl.boltless.dev 12 + PDS_PORT=3000 13 + # PDS_CRAWLERS=https://relay.tngl.boltless.dev
+3
localinfra/postgres-init.sql
··· 1 + CREATE DATABASE plc; 2 + CREATE DATABASE mirror; 3 + CREATE DATABASE mirror_tap;
+48
localinfra/readme.md
··· 1 + Heavily inspired by [frontpage dev environment](https://github.com/frontpagefyi/frontpage/blob/10678df9c3f72cbd82f0856a9f99c74dd22326d8/apps/frontpage/local-infra/README.md). 2 + Tangled's setup is slightly more involved because services inside the network need to reach the PDS over its **public** hostname with **valid TLS** — federation paths (DID resolution, OAuth, etc.) round-trip through the same URLs an external client would use. 3 + 4 + For example, resolving `alice.pds.tngl.boltless.dev` yields an `#atproto_pds` service pointing at `https://pds.tngl.boltless.dev`. Knot and spindle running inside docker must hit that exact URL and trust its cert. 5 + 6 + To make that work: 7 + 8 + - Caddy's dev root CA is mounted into every container that talks to another service over HTTPS. 9 + - The Docker network uses an unrouted "public" subnet so the SSRF dialer doesn't reject container IPs as private. 10 + 11 + ## What's inside: 12 + 13 + - [did-method-plc](https://github.com/did-method-plc/did-method-plc) (<https://plc.tngl.boltless.dev>) 14 + - atproto_pds (<https://pds.tngl.boltless.dev>) 15 + - jetstream (<https://jetstream.tngl.boltless.dev>) 16 + - knot (<https://knot.tngl.boltless.dev>) 17 + - knotmirror (<https://knotmirror.tngl.boltless.dev>) 18 + - appview (<https://tngl.boltless.dev>) (live reloading) 19 + - caddy reverse proxy 20 + 21 + > [!NOTE] 22 + > Spindle is not included yet. 23 + 24 + ## Setup 25 + 26 + 1. Generate the dev CA from the repo root: 27 + ```bash 28 + mkdir -p localinfra/certs && 29 + openssl req -x509 -newkey rsa:2048 \ 30 + -keyout localinfra/certs/root.key \ 31 + -out localinfra/certs/root.crt \ 32 + -days 3650 -nodes \ 33 + -subj "/CN=Tangled Dev CA" \ 34 + -addext "basicConstraints=critical,CA:TRUE,pathlen:1" \ 35 + -addext "keyUsage=critical,keyCertSign,cRLSign" \ 36 + -addext "nameConstraints=critical,permitted;DNS:tngl.boltless.dev" 37 + ``` 38 + 2. Trust generated `localinfra/certs/root.crt` in your system's trust store. 39 + - For example in MacOS, run 40 + ```bash 41 + sudo security add-trusted-cert -d -r trustRoot \ 42 + -k /Library/Keychains/System.keychain \ 43 + ./localinfra/certs/root.crt 44 + ``` 45 + - Depending on your browser you may have to import the certificate into your browser profiles too as some have their own certs do not use your system ones 46 + 3. run `./localinfra/scripts/appview-static-files.sh` 47 + 4. `docker compose up` 48 + 5. AppView will be running on `127.0.0.1:3000` with two test users: `alice.pds.tngl.boltless.dev` and `bob.pds.tngl.boltless.dev`. Both with password `password`.
+44
localinfra/scripts/appview-static-files.sh
··· 1 + #!/usr/bin/env bash 2 + set -euo pipefail 3 + 4 + HTMX_URL="https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js" 5 + HTMX_WS_URL="https://cdn.jsdelivr.net/npm/htmx-ext-ws@2.0.2" 6 + MERMAID_URL="https://cdn.jsdelivr.net/npm/mermaid@11.12.3/dist/mermaid.min.js" 7 + LUCIDE_URL="https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip" 8 + INTER_URL="https://github.com/rsms/inter/releases/download/v4.1/Inter-4.1.zip" 9 + PLEX_MONO_URL="https://github.com/IBM/plex/releases/download/%40ibm%2Fplex-mono%401.1.0/ibm-plex-mono.zip" 10 + ACTOR_TYPEAHEAD_REPO="https://tangled.org/@jakelazaroff.com/actor-typeahead" 11 + 12 + REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" 13 + OUT="$REPO_ROOT/appview/pages/static" 14 + TMP="$(mktemp -d)" 15 + # trap 'rm -rf "$TMP"' EXIT 16 + 17 + mkdir -p "$OUT"/{fonts,icons,logos} 18 + 19 + curl -fsSL -o "$OUT/htmx.min.js" "$HTMX_URL" 20 + curl -fsSL -o "$OUT/htmx-ext-ws.min.js" "$HTMX_WS_URL" 21 + curl -fsSL -o "$OUT/mermaid.min.js" "$MERMAID_URL" 22 + 23 + curl -fsSL -o "$TMP/lucide.zip" "$LUCIDE_URL" 24 + unzip -q "$TMP/lucide.zip" -d "$TMP/lucide" 25 + cp -rf "$TMP"/lucide/icons/*.svg "$OUT/icons/" 26 + 27 + curl -fsSL -o "$TMP/inter.zip" "$INTER_URL" 28 + unzip -q "$TMP/inter.zip" -d "$TMP/inter" 29 + cp -f "$TMP"/inter/web/InterVariable*.woff2 "$OUT/fonts/" 30 + cp -f "$TMP"/inter/web/InterDisplay*.woff2 "$OUT/fonts/" 31 + cp -f "$TMP"/inter/InterVariable*.ttf "$OUT/fonts/" 32 + 33 + curl -fsSL -o "$TMP/plex.zip" "$PLEX_MONO_URL" 34 + unzip -q "$TMP/plex.zip" -d "$TMP/plex" 35 + cp -f "$TMP"/plex/ibm-plex-mono/fonts/complete/woff2/IBMPlexMono*.woff2 "$OUT/fonts/" 36 + 37 + git clone --depth=1 "$ACTOR_TYPEAHEAD_REPO" "$TMP/actor-typeahead" 38 + cp -f "$TMP/actor-typeahead/actor-typeahead.js" "$OUT/" 39 + 40 + (cd "$REPO_ROOT" && go build -o "$TMP/dolly" ./cmd/dolly) 41 + TEMPLATE="$REPO_ROOT/appview/pages/templates/fragments/dolly/logo.html" 42 + "$TMP/dolly" -template "$TEMPLATE" -output "$OUT/logos/dolly.png" -size 180x180 43 + "$TMP/dolly" -template "$TEMPLATE" -output "$OUT/logos/dolly.ico" -size 48x48 44 + "$TMP/dolly" -template "$TEMPLATE" -output "$OUT/logos/dolly.svg" -color currentColor
+191
localinfra/scripts/init-accounts.sh
··· 1 + #!/bin/sh 2 + # dev bootstrap: 3 + # - create accounts (alice, bob) 4 + # - write OWNER_DID to /shared/owner-did (for knot/spindle) 5 + # - create system label definitions under SYSTEM_DID 6 + set -eu 7 + 8 + : "${PDS_URL:?PDS_URL must be set}" 9 + PASSWORD="password" 10 + 11 + USERS="alice bob" 12 + OWNER_USER="${OWNER_USER:-alice}" 13 + SYSTEM_USER="${SYSTEM_USER:-alice}" 14 + SHARED_DIR="${SHARED_DIR:-/shared}" 15 + 16 + # --- helpers --- 17 + 18 + # resolve_handle HANDLE → DID on stdout 19 + resolve_handle() { 20 + resp=$(curl -sS -w '\n%{http_code}' \ 21 + "${PDS_URL}/xrpc/com.atproto.identity.resolveHandle?handle=$1") 22 + body=$(printf '%s\n' "$resp" | sed '$d') 23 + status=$(printf '%s\n' "$resp" | tail -n1) 24 + case "$status" in 25 + 200) printf '%s\n' "$body" | jq -er '.did' ;; 26 + 400) : ;; # not found — expected 27 + *) printf 'resolveHandle %s: HTTP %s: %s\n' "$1" "$status" "$body" >&2; return 1 ;; 28 + esac 29 + } 30 + 31 + # ensure_account USERNAME → DID on stdout. Creates account if missing. 32 + ensure_account() { 33 + username="$1" 34 + handle="${username}.${PDS_HOSTNAME}" 35 + email="${username}@${PDS_HOSTNAME}" 36 + 37 + did=$(resolve_handle "$handle") 38 + if [ -n "$did" ]; then 39 + printf '[skip] %s = %s\n' "$handle" "$did" >&2 40 + printf '%s\n' "$did" 41 + return 0 42 + fi 43 + 44 + invite=$(curl -fsS -u "admin:${PDS_ADMIN_PASSWORD}" \ 45 + -H "Content-Type: application/json" \ 46 + -d '{"useCount":1}' \ 47 + "${PDS_URL}/xrpc/com.atproto.server.createInviteCode" | jq -er '.code') 48 + 49 + result=$(curl -fsS \ 50 + -H "Content-Type: application/json" \ 51 + -d "{\"email\":\"${email}\",\"handle\":\"${handle}\",\"password\":\"${PASSWORD}\",\"inviteCode\":\"${invite}\"}" \ 52 + "${PDS_URL}/xrpc/com.atproto.server.createAccount") 53 + 54 + did=$(printf '%s\n' "$result" | jq -er '.did') 55 + printf '[create] %s = %s (password: %s)\n' "$handle" "$did" "$PASSWORD" >&2 56 + printf '%s\n' "$did" 57 + } 58 + 59 + # login DID/Handle → access JWT on stdout 60 + login() { 61 + curl -fsS -H "Content-Type: application/json" \ 62 + -d "{\"identifier\":\"$1\",\"password\":\"${PASSWORD}\"}" \ 63 + "${PDS_URL}/xrpc/com.atproto.server.createSession" \ 64 + | jq -er '.accessJwt' 65 + } 66 + 67 + # put_record JWT DID COLLECTION RKEY RECORD_JSON 68 + put_record() { 69 + jwt="$1"; did="$2"; collection="$3"; rkey="$4"; record="$5" 70 + 71 + payload=$(jq -nc \ 72 + --arg repo "$did" \ 73 + --arg collection "$collection" \ 74 + --arg rkey "$rkey" \ 75 + --argjson record "$record" \ 76 + '{repo:$repo, collection:$collection, rkey:$rkey, record:$record}') 77 + 78 + curl -fsS \ 79 + -H "Content-Type: application/json" \ 80 + -H "Authorization: Bearer ${jwt}" \ 81 + -d "$payload" \ 82 + "${PDS_URL}/xrpc/com.atproto.repo.putRecord" >/dev/null 83 + 84 + printf '[record] at://%s/%s/%s\n' "$did" "$collection" "$rkey" >&2 85 + } 86 + 87 + # ensure accounts 88 + OWNER_DID="" 89 + SYSTEM_DID="" 90 + for u in $USERS; do 91 + did=$(ensure_account "$u") 92 + if [ "$u" = "$OWNER_USER" ]; then 93 + OWNER_DID="$did" 94 + fi 95 + if [ "$u" = "$SYSTEM_USER" ]; then 96 + SYSTEM_DID="$did" 97 + fi 98 + done 99 + 100 + [ -n "$OWNER_DID" ] || { printf 'OWNER_USER %s not in USERS list\n' "$OWNER_USER" >&2; exit 1; } 101 + [ -n "$SYSTEM_DID" ] || { printf 'SYSTEM_USER %s not in USERS list\n' "$SYSTEM_USER" >&2; exit 1; } 102 + 103 + mkdir -p "$SHARED_DIR" 104 + printf '%s' "$OWNER_DID" > "${SHARED_DIR}/owner-did" 105 + printf '[owner] %s → %s/owner-did\n' "$OWNER_USER" "$SHARED_DIR" >&2 106 + printf '%s' "$SYSTEM_DID" > "${SHARED_DIR}/system-did" 107 + printf '[system] %s → %s/system-did\n' "$SYSTEM_USER" "$SHARED_DIR" >&2 108 + 109 + # label definitions (under SYSTEM_DID) 110 + JWT=$(login "$SYSTEM_DID") 111 + 112 + CREATED_AT="2025-09-22T11:14:35+01:00" 113 + 114 + put_record "$JWT" "$SYSTEM_DID" "sh.tangled.label.definition" "wontfix" "$(cat <<JSON 115 + { 116 + "name": "wontfix", 117 + "color": "#64748b", 118 + "scope": ["sh.tangled.repo.issue"], 119 + "multiple": false, 120 + "createdAt": "${CREATED_AT}", 121 + "valueType": {"type": "null", "format": "any"} 122 + } 123 + JSON 124 + )" 125 + 126 + put_record "$JWT" "$SYSTEM_DID" "sh.tangled.label.definition" "good-first-issue" "$(cat <<JSON 127 + { 128 + "name": "good-first-issue", 129 + "color": "#8B5CF6", 130 + "scope": ["sh.tangled.repo.issue"], 131 + "multiple": false, 132 + "createdAt": "${CREATED_AT}", 133 + "valueType": {"type": "null", "format": "any"} 134 + } 135 + JSON 136 + )" 137 + 138 + put_record "$JWT" "$SYSTEM_DID" "sh.tangled.label.definition" "duplicate" "$(cat <<JSON 139 + { 140 + "name": "duplicate", 141 + "color": "#ef4444", 142 + "scope": ["sh.tangled.repo.issue"], 143 + "multiple": false, 144 + "createdAt": "${CREATED_AT}", 145 + "valueType": {"type": "null", "format": "any"} 146 + } 147 + JSON 148 + )" 149 + 150 + put_record "$JWT" "$SYSTEM_DID" "sh.tangled.label.definition" "documentation" "$(cat <<JSON 151 + { 152 + "name": "documentation", 153 + "color": "#06b6d4", 154 + "scope": ["sh.tangled.repo.issue"], 155 + "multiple": false, 156 + "createdAt": "${CREATED_AT}", 157 + "valueType": {"type": "null", "format": "any"} 158 + } 159 + JSON 160 + )" 161 + 162 + put_record "$JWT" "$SYSTEM_DID" "sh.tangled.label.definition" "assignee" "$(cat <<JSON 163 + { 164 + "name": "assignee", 165 + "color": "#10B981", 166 + "scope": ["sh.tangled.repo.issue", "sh.tangled.repo.pull"], 167 + "multiple": false, 168 + "createdAt": "${CREATED_AT}", 169 + "valueType": {"type": "string", "format": "did"} 170 + } 171 + JSON 172 + )" 173 + 174 + # shared env values for appview 175 + LABEL_GFI=at://${SYSTEM_DID}/sh.tangled.label.definition/good-first-issue 176 + LABEL_DEFAULTS=$LABEL_GFI 177 + LABEL_DEFAULTS=$LABEL_DEFAULTS,at://${SYSTEM_DID}/sh.tangled.label.definition/assignee 178 + LABEL_DEFAULTS=$LABEL_DEFAULTS,at://${SYSTEM_DID}/sh.tangled.label.definition/documentation 179 + LABEL_DEFAULTS=$LABEL_DEFAULTS,at://${SYSTEM_DID}/sh.tangled.label.definition/duplicate 180 + LABEL_DEFAULTS=$LABEL_DEFAULTS,at://${SYSTEM_DID}/sh.tangled.label.definition/wontfix 181 + 182 + printf '%s' "$LABEL_GFI" > "${SHARED_DIR}/label-gfi" 183 + printf '%s' "$LABEL_DEFAULTS" > "${SHARED_DIR}/label-defaults" 184 + printf '[env] wrote label-defaults, label-gfi\n' >&2 185 + 186 + # service definitions (under OWNER_DID) 187 + JWT=$(login "$OWNER_DID") 188 + 189 + put_record "$JWT" "$OWNER_DID" "sh.tangled.knot" $KNOT_HOSTNAME "{\"createdAt\": \"${CREATED_AT}\"}" 190 + 191 + printf 'done.\n' >&2