forked from tangled.org/core
Monorepo for Tangled

contrib,nix: local, sandboxed atmosphere infra

Add sandboxed atmosphere environment for local testing. This new vm
contains everything required to run local test appview including PLC,
PDS, Jetstream (listening to single PDS), knot and spindle.

I'm using my custom `tngl.boltless.dev` domain which resolves to
`127.0.0.1` without any proxy.

PLC: plc.tngl.boltless.dev
PDS: pds.tngl.boltless.dev
Relay: relay.tngl.boltless.dev
Jetstream: jetstream.tngl.boltless.dev
Knot: knot.tngl.boltless.dev
Spindle: spindle.tngl.boltless.dev

TLS is supported with caddy service running inside the vm.

note: `pds.env` file here is hard copy to be used for contrib/scripts.
note: upgraded pds package in order to set email settings

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

boltless.me 95a7ac4d aef0dae3

verified
+11
contrib/certs/root.crt
··· 1 + -----BEGIN CERTIFICATE----- 2 + MIIBpDCCAUmgAwIBAgIQKU9d61/WZ56BCZVYfEC6sTAKBggqhkjOPQQDAjAwMS4w 3 + LAYDVQQDEyVDYWRkeSBMb2NhbCBBdXRob3JpdHkgLSAyMDI1IEVDQyBSb290MB4X 4 + DTI1MTIxNDE4MTgzNVoXDTM1MTAyMzE4MTgzNVowMDEuMCwGA1UEAxMlQ2FkZHkg 5 + TG9jYWwgQXV0aG9yaXR5IC0gMjAyNSBFQ0MgUm9vdDBZMBMGByqGSM49AgEGCCqG 6 + SM49AwEHA0IABPvHcpXJqjBY65eTkPvOVrYU7hG3mUHo2uKLNk4UU5pp0u8f0Lnr 7 + qGfdnsE0OI5p/+VPlwWJADZYAU3sr6+wkRajRTBDMA4GA1UdDwEB/wQEAwIBBjAS 8 + BgNVHRMBAf8ECDAGAQH/AgEBMB0GA1UdDgQWBBRdJ3V1QlZggp4ajYwGyLC6lNzq 9 + JzAKBggqhkjOPQQDAgNJADBGAiEAr0hlnlWKC5PQXeguOcaEZZN/2+yxc5GdQTfv 10 + 66DO4XICIQC6yZaLrKjwPlghYsgT2ysgnboJTfrpwrO4+Naa5leZNg== 11 + -----END CERTIFICATE-----
+31
contrib/example.env
··· 1 + # NOTE: put actual DIDs here 2 + alice_did=did:plc:alice-did 3 + tangled_did=did:plc:tangled-did 4 + 5 + #core 6 + export TANGLED_DEV=true 7 + export TANGLED_APPVIEW_HOST=http://127.0.0.1:3000 8 + # plc 9 + export TANGLED_PLC_URL=https://plc.tngl.boltless.dev 10 + # jetstream 11 + export TANGLED_JETSTREAM_ENDPOINT=wss://jetstream.tngl.boltless.dev/subscribe 12 + # label 13 + export TANGLED_LABEL_GFI=at://${tangled_did}/sh.tangled.label.definition/good-first-issue 14 + export TANGLED_LABEL_DEFAULTS=$TANGLED_LABEL_GFI 15 + export TANGLED_LABEL_DEFAULTS=$TANGLED_LABEL_DEFAULTS,at://${tangled_did}/sh.tangled.label.definition/assignee 16 + export TANGLED_LABEL_DEFAULTS=$TANGLED_LABEL_DEFAULTS,at://${tangled_did}/sh.tangled.label.definition/documentation 17 + export TANGLED_LABEL_DEFAULTS=$TANGLED_LABEL_DEFAULTS,at://${tangled_did}/sh.tangled.label.definition/duplicate 18 + export TANGLED_LABEL_DEFAULTS=$TANGLED_LABEL_DEFAULTS,at://${tangled_did}/sh.tangled.label.definition/wontfix 19 + 20 + # vm settings 21 + export TANGLED_VM_PLC_URL=https://plc.tngl.boltless.dev 22 + export TANGLED_VM_JETSTREAM_ENDPOINT=wss://jetstream.tngl.boltless.dev/subscribe 23 + export TANGLED_VM_KNOT_HOST=knot.tngl.boltless.dev 24 + export TANGLED_VM_KNOT_OWNER=$alice_did 25 + export TANGLED_VM_SPINDLE_HOST=spindle.tngl.boltless.dev 26 + export TANGLED_VM_SPINDLE_OWNER=$alice_did 27 + 28 + if [ -n "${TANGLED_RESEND_API_KEY:-}" ] && [ -n "${TANGLED_RESEND_SENT_FROM:-}" ]; then 29 + export TANGLED_VM_PDS_EMAIL_SMTP_URL=smtps://resend:$TANGLED_RESEND_API_KEY@smtp.resend.com:465/ 30 + export TANGLED_VM_PDS_EMAIL_FROM_ADDRESS=$TANGLED_RESEND_SENT_FROM 31 + fi
+12
contrib/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
+25
contrib/readme.md
··· 1 + # how to setup local appview dev environment 2 + 3 + Appview requires several microservices from knot and spindle to entire atproto infra. This test environment is implemented under nixos vm. 4 + 5 + 1. copy `contrib/example.env` to `.env`, fill it and source it 6 + 2. run vm 7 + ```bash 8 + nix run --impure .#vm 9 + ``` 10 + 3. trust the generated cert from host machine 11 + ```bash 12 + # for macos 13 + sudo security add-trusted-cert -d -r trustRoot \ 14 + -k /Library/Keychains/System.keychain \ 15 + ./nix/vm-data/caddy/.local/share/caddy/pki/authorities/local/root.crt 16 + ``` 17 + 4. create test accounts with valid emails (use [`create-test-account.sh`](./scripts/create-test-account.sh)) 18 + 5. create default labels (use [`setup-const-records`](./scripts/setup-const-records.sh)) 19 + 6. restart vm with correct owner-did 20 + 21 + for git-https, you should change your local git config: 22 + ``` 23 + [http "https://knot.tngl.boltless.dev"] 24 + sslCAPath = /Users/boltless/repo/tangled/nix/vm-data/caddy/.local/share/caddy/pki/authorities/local/ 25 + ```
+68
contrib/scripts/create-test-account.sh
··· 1 + #!/bin/bash 2 + set -o errexit 3 + set -o nounset 4 + set -o pipefail 5 + 6 + source "$(dirname "$0")/../pds.env" 7 + 8 + # PDS_HOSTNAME= 9 + # PDS_ADMIN_PASSWORD= 10 + 11 + # curl a URL and fail if the request fails. 12 + function curl_cmd_get { 13 + curl --fail --silent --show-error "$@" 14 + } 15 + 16 + # curl a URL and fail if the request fails. 17 + function curl_cmd_post { 18 + curl --fail --silent --show-error --request POST --header "Content-Type: application/json" "$@" 19 + } 20 + 21 + # curl a URL but do not fail if the request fails. 22 + function curl_cmd_post_nofail { 23 + curl --silent --show-error --request POST --header "Content-Type: application/json" "$@" 24 + } 25 + 26 + USERNAME="${1:-}" 27 + 28 + if [[ "${USERNAME}" == "" ]]; then 29 + read -p "Enter a username: " USERNAME 30 + fi 31 + 32 + if [[ "${USERNAME}" == "" ]]; then 33 + echo "ERROR: missing USERNAME parameter." >/dev/stderr 34 + echo "Usage: $0 ${SUBCOMMAND} <USERNAME>" >/dev/stderr 35 + exit 1 36 + fi 37 + 38 + EMAIL=${USERNAME}@${PDS_HOSTNAME} 39 + 40 + PASSWORD="password" 41 + INVITE_CODE="$(curl_cmd_post \ 42 + --user "admin:${PDS_ADMIN_PASSWORD}" \ 43 + --data '{"useCount": 1}' \ 44 + "https://${PDS_HOSTNAME}/xrpc/com.atproto.server.createInviteCode" | jq --raw-output '.code' 45 + )" 46 + RESULT="$(curl_cmd_post_nofail \ 47 + --data "{\"email\":\"${EMAIL}\", \"handle\":\"${USERNAME}.${PDS_HOSTNAME}\", \"password\":\"${PASSWORD}\", \"inviteCode\":\"${INVITE_CODE}\"}" \ 48 + "https://${PDS_HOSTNAME}/xrpc/com.atproto.server.createAccount" 49 + )" 50 + 51 + DID="$(echo $RESULT | jq --raw-output '.did')" 52 + if [[ "${DID}" != did:* ]]; then 53 + ERR="$(echo ${RESULT} | jq --raw-output '.message')" 54 + echo "ERROR: ${ERR}" >/dev/stderr 55 + echo "Usage: $0 <EMAIL> <HANDLE>" >/dev/stderr 56 + exit 1 57 + fi 58 + 59 + echo 60 + echo "Account created successfully!" 61 + echo "-----------------------------" 62 + echo "Handle : ${USERNAME}.${PDS_HOSTNAME}" 63 + echo "DID : ${DID}" 64 + echo "Password : ${PASSWORD}" 65 + echo "-----------------------------" 66 + echo "This is a test account with an insecure password." 67 + echo "Make sure it's only used for development." 68 + echo
+106
contrib/scripts/setup-const-records.sh
··· 1 + #!/bin/bash 2 + set -o errexit 3 + set -o nounset 4 + set -o pipefail 5 + 6 + source "$(dirname "$0")/../pds.env" 7 + 8 + # PDS_HOSTNAME= 9 + 10 + # curl a URL and fail if the request fails. 11 + function curl_cmd_get { 12 + curl --fail --silent --show-error "$@" 13 + } 14 + 15 + # curl a URL and fail if the request fails. 16 + function curl_cmd_post { 17 + curl --fail --silent --show-error --request POST --header "Content-Type: application/json" "$@" 18 + } 19 + 20 + # curl a URL but do not fail if the request fails. 21 + function curl_cmd_post_nofail { 22 + curl --silent --show-error --request POST --header "Content-Type: application/json" "$@" 23 + } 24 + 25 + USERNAME="${1:-}" 26 + 27 + if [[ "${USERNAME}" == "" ]]; then 28 + read -p "Enter a username: " USERNAME 29 + fi 30 + 31 + if [[ "${USERNAME}" == "" ]]; then 32 + echo "ERROR: missing USERNAME parameter." >/dev/stderr 33 + echo "Usage: $0 ${SUBCOMMAND} <USERNAME>" >/dev/stderr 34 + exit 1 35 + fi 36 + 37 + SESS_RESULT="$(curl_cmd_post \ 38 + --data "$(cat <<EOF 39 + { 40 + "identifier": "$USERNAME", 41 + "password": "password" 42 + } 43 + EOF 44 + )" \ 45 + https://pds.tngl.boltless.dev/xrpc/com.atproto.server.createSession 46 + )" 47 + 48 + echo $SESS_RESULT | jq 49 + 50 + DID="$(echo $SESS_RESULT | jq --raw-output '.did')" 51 + ACCESS_JWT="$(echo $SESS_RESULT | jq --raw-output '.accessJwt')" 52 + 53 + function add_label_def { 54 + local color=$1 55 + local name=$2 56 + echo $color 57 + echo $name 58 + local json_payload=$(cat <<EOF 59 + { 60 + "repo": "$DID", 61 + "collection": "sh.tangled.label.definition", 62 + "rkey": "$name", 63 + "record": { 64 + "name": "$name", 65 + "color": "$color", 66 + "scope": ["sh.tangled.repo.issue"], 67 + "multiple": false, 68 + "createdAt": "2025-09-22T11:14:35+01:00", 69 + "valueType": {"type": "null", "format": "any"} 70 + } 71 + } 72 + EOF 73 + ) 74 + echo $json_payload 75 + echo $json_payload | jq 76 + RESULT="$(curl_cmd_post \ 77 + --data "$json_payload" \ 78 + -H "Authorization: Bearer ${ACCESS_JWT}" \ 79 + "https://${PDS_HOSTNAME}/xrpc/com.atproto.repo.createRecord")" 80 + echo $RESULT | jq 81 + } 82 + 83 + add_label_def '#64748b' 'wontfix' 84 + add_label_def '#8B5CF6' 'good-first-issue' 85 + add_label_def '#ef4444' 'duplicate' 86 + add_label_def '#06b6d4' 'documentation' 87 + json_payload=$(cat <<EOF 88 + { 89 + "repo": "$DID", 90 + "collection": "sh.tangled.label.definition", 91 + "rkey": "assignee", 92 + "record": { 93 + "name": "assignee", 94 + "color": "#10B981", 95 + "scope": ["sh.tangled.repo.issue", "sh.tangled.repo.pull"], 96 + "multiple": false, 97 + "createdAt": "2025-09-22T11:14:35+01:00", 98 + "valueType": {"type": "string", "format": "did"} 99 + } 100 + } 101 + EOF 102 + ) 103 + curl_cmd_post \ 104 + --data "$json_payload" \ 105 + -H "Authorization: Bearer ${ACCESS_JWT}" \ 106 + "https://${PDS_HOSTNAME}/xrpc/com.atproto.repo.createRecord"
+1 -1
flake.nix
··· 236 236 rootDir=$(jj --ignore-working-copy root || git rev-parse --show-toplevel) || (echo "error: can't find repo root?"; exit 1) 237 237 cd "$rootDir" 238 238 239 - mkdir -p nix/vm-data/{knot,repos,spindle,spindle-logs} 239 + mkdir -p nix/vm-data/{caddy,knot,repos,spindle,spindle-logs} 240 240 241 241 export TANGLED_VM_DATA_DIR="$rootDir/nix/vm-data" 242 242 exec ${pkgs.lib.getExe
+122
nix/vm.nix
··· 23 23 nixpkgs.lib.nixosSystem { 24 24 inherit system; 25 25 modules = [ 26 + self.nixosModules.did-method-plc 27 + self.nixosModules.bluesky-jetstream 28 + self.nixosModules.bluesky-relay 26 29 self.nixosModules.knot 27 30 self.nixosModules.spindle 28 31 ({ ··· 39 42 diskSize = 10 * 1024; 40 43 cores = 2; 41 44 forwardPorts = [ 45 + # caddy 46 + { 47 + from = "host"; 48 + host.port = 80; 49 + guest.port = 80; 50 + } 51 + { 52 + from = "host"; 53 + host.port = 443; 54 + guest.port = 443; 55 + } 56 + { 57 + from = "host"; 58 + proto = "udp"; 59 + host.port = 443; 60 + guest.port = 443; 61 + } 42 62 # ssh 43 63 { 44 64 from = "host"; ··· 63 83 # as SQLite is incompatible with them. So instead we 64 84 # mount the shared directories to a different location 65 85 # and copy the contents around on service start/stop. 86 + caddyData = { 87 + source = "$TANGLED_VM_DATA_DIR/caddy"; 88 + target = config.services.caddy.dataDir; 89 + }; 66 90 knotData = { 67 91 source = "$TANGLED_VM_DATA_DIR/knot"; 68 92 target = "/mnt/knot-data"; ··· 79 103 }; 80 104 # This is fine because any and all ports that are forwarded to host are explicitly marked above, we don't need a separate guest firewall 81 105 networking.firewall.enable = false; 106 + # resolve `*.tngl.boltless.dev` to host 107 + services.dnsmasq.enable = true; 108 + services.dnsmasq.settings.address = "/tngl.boltless.dev/10.0.2.2"; 109 + security.pki.certificates = [ 110 + (builtins.readFile ../contrib/certs/root.crt) 111 + ]; 82 112 time.timeZone = "Europe/London"; 113 + services.timesyncd.enable = lib.mkVMOverride true; 83 114 services.getty.autologinUser = "root"; 84 115 environment.systemPackages = with pkgs; [curl vim git sqlite litecli]; 116 + virtualisation.docker.extraOptions = '' 117 + --dns 172.17.0.1 118 + ''; 85 119 services.tangled.knot = { 86 120 enable = true; 87 121 motd = "Welcome to the development knot!\n"; ··· 108 142 provider = "sqlite"; 109 143 }; 110 144 }; 145 + }; 146 + services.did-method-plc.enable = true; 147 + services.bluesky-pds = { 148 + enable = true; 149 + # overriding package version to support emails 150 + package = pkgs.bluesky-pds.overrideAttrs (old: rec { 151 + version = "0.4.188"; 152 + src = pkgs.fetchFromGitHub { 153 + owner = "bluesky-social"; 154 + repo = "pds"; 155 + tag = "v${version}"; 156 + hash = "sha256-t8KdyEygXdbj/5Rhj8W40e1o8mXprELpjsKddHExmo0="; 157 + }; 158 + pnpmDeps = pkgs.pnpm_9.fetchDeps { 159 + inherit version src; 160 + pname = old.pname; 161 + sourceRoot = old.sourceRoot; 162 + fetcherVersion = 2; 163 + hash = "sha256-lQie7f8JbWKSpoavnMjHegBzH3GB9teXsn+S2SLJHHU="; 164 + }; 165 + }); 166 + settings = { 167 + LOG_ENABLED = "true"; 168 + 169 + PDS_JWT_SECRET = "8cae8bffcc73d9932819650791e4e89a"; 170 + PDS_ADMIN_PASSWORD = "d6a902588cd93bee1af83f924f60cfd3"; 171 + PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX = "2e92e336a50a618458e1097d94a1db86ec3fd8829d7735020cbae80625c761d7"; 172 + 173 + PDS_EMAIL_SMTP_URL = envVarOr "TANGLED_VM_PDS_EMAIL_SMTP_URL" null; 174 + PDS_EMAIL_FROM_ADDRESS = envVarOr "TANGLED_VM_PDS_EMAIL_FROM_ADDRESS" null; 175 + 176 + PDS_DID_PLC_URL = "http://localhost:8080"; 177 + PDS_CRAWLERS = "https://relay.tngl.boltless.dev"; 178 + PDS_HOSTNAME = "pds.tngl.boltless.dev"; 179 + PDS_PORT = 3000; 180 + }; 181 + }; 182 + services.bluesky-relay = { 183 + enable = true; 184 + }; 185 + services.bluesky-jetstream = { 186 + enable = true; 187 + livenessTtl = 300; 188 + websocketUrl = "ws://localhost:3000/xrpc/com.atproto.sync.subscribeRepos"; 189 + }; 190 + services.caddy = { 191 + enable = true; 192 + configFile = pkgs.writeText "Caddyfile" '' 193 + { 194 + debug 195 + cert_lifetime 3601d 196 + pki { 197 + ca local { 198 + intermediate_lifetime 3599d 199 + } 200 + } 201 + } 202 + 203 + plc.tngl.boltless.dev { 204 + tls internal 205 + reverse_proxy http://localhost:8080 206 + } 207 + 208 + *.pds.tngl.boltless.dev, pds.tngl.boltless.dev { 209 + tls internal 210 + reverse_proxy http://localhost:3000 211 + } 212 + 213 + jetstream.tngl.boltless.dev { 214 + tls internal 215 + reverse_proxy http://localhost:6008 216 + } 217 + 218 + relay.tngl.boltless.dev { 219 + tls internal 220 + reverse_proxy http://localhost:2470 221 + } 222 + 223 + knot.tngl.boltless.dev { 224 + tls internal 225 + reverse_proxy http://localhost:6444 226 + } 227 + 228 + spindle.tngl.boltless.dev { 229 + tls internal 230 + reverse_proxy http://localhost:6555 231 + } 232 + ''; 111 233 }; 112 234 users = { 113 235 # So we don't have to deal with permission clashing between