#!/usr/bin/env bash set -euo pipefail # Stand up a second AT Protocol PDS on the production host, at # pds2.socialweb.computer, and create one test account on it. # # ./scripts/deploy-pds2.sh --dry-run # ./scripts/deploy-pds2.sh # # Why a second PDS at all: the k8s door's node contracts are written into the # market's own collections (com.publicdomainrelay.temp.*). A public PDS refuses # records in a collection it does not know, so the identity that drives the door # has to live on a PDS we run. This script produces that identity. # # WHY THE CODE COMES FROM THE HOST'S OWN TREE # # The unit runs ts/hono-pds/main.ts out of a socialweb-computer-kcp checkout. Its # @publicdomainrelay/* imports resolve through the root deno.json workspace, so # the shipped set is that workspace: deno.json, deno.lock, ts/ and # socialweb-computer-k8s/. # # The tree it is copied from is a variable (SOURCE_ROOT, default # /opt/socialweb-computer-kcp) and it is the one already on this host. That is not a shortcut: the version of # hono-pds in this repo keeps accounts in a `new Map` # (ts/hono-pds/lib/atproto-repo-deno/account-store.ts), so every restart would erase the # account and every re-run of this script would mint a second did:plc for one # handle. The tree already on the host persists accounts to Deno KV # (accountStorage in main.ts, "accounts" prefix in account-store.ts). A tree # without that wiring is refused below rather than deployed. # # WHY THE HANDLE NEEDS NO DNS RECORD TO RESOLVE # # A handle proves itself to a client either by a DNS TXT at _atproto. or # by https:///.well-known/atproto-did. This PDS answers that path with # its OWN did (hono-pds factory.ts), not with the account's, so the HTTPS method # cannot come from the PDS process. It comes from Caddy instead: a second site # block for the handle's hostname answers that one path with the account's did # and proxies everything else to the PDS. *.socialweb.computer already resolves # here, so Caddy can get a certificate for the handle host over HTTP-01 and no # operator action is needed. The equivalent TXT record is printed at the end # anyway, because the DNS method is the one that survives this site block being # removed. # # NOTHING HERE TOUCHES AN EXISTING UNIT. The only shared unit it reloads is # Caddy, and only through `caddy validate` + `systemctl reload` after adding a # drop-in under /etc/caddy/sites/. Every other unit on the host is left alone. SSH_TARGET="${SSH_TARGET:-root@socialweb.computer}" SSH_PORT="${SSH_PORT:-1997}" SSH_KEY="${SSH_KEY:-$HOME/.ssh/id_ed25519_no_password}" SSH_OPTS=(-o StrictHostKeyChecking=accept-new -o BatchMode=yes -o ConnectTimeout=20 -o ServerAliveInterval=15) SERVICE_NAME="pds2" SERVICE_USER="pds2" ORIGIN="pds2.socialweb.computer" HANDLE="test.pds2.socialweb.computer" ACCOUNT_EMAIL="test@pds2.socialweb.computer" PORT="8794" PREFIX="/opt/socialweb-pds2" SOURCE_ROOT="/opt/socialweb-computer-kcp" ENTRY_CONFIG="deno.json" ENTRYPOINT="ts/hono-pds/main.ts" STATE_DIR="/var/lib/pds2" DENO="/usr/local/bin/deno" PLC_DIRECTORY_URL="https://plc.directory" BIND="127.0.0.1" PROBE_COLLECTION="com.publicdomainrelay.temp.badgeBlueKeys" CADDY_SITES_DIR="/etc/caddy/sites" CADDYFILE="/etc/caddy/Caddyfile" ENV_FILE="${STATE_DIR}/pds.env" CONFIG_PATH="${STATE_DIR}/config.json" PASSWORD_FILE="${STATE_DIR}/account-password" DID_FILE="${STATE_DIR}/account-did" DRY_RUN=false for arg in "$@"; do case "$arg" in --dry-run) DRY_RUN=true ;; -h|--help) sed -n '2,45p' "$0"; exit 0 ;; *) echo "unknown argument: $arg" >&2; exit 2 ;; esac done say() { printf '\n=== %s ===\n' "$*"; } # Every remote command goes through here, so --dry-run is one branch rather than # a flag threaded through every step. remote() { if ${DRY_RUN}; then printf -- '--- [dry-run] ssh %s -p %s %s\n' "${SSH_TARGET}" "${SSH_PORT}" "$*" else ssh "${SSH_OPTS[@]}" -i "${SSH_KEY}" -p "${SSH_PORT}" "${SSH_TARGET}" "$@" fi } # The local values are handed to the remote script as shell assignments so the # previewed script reads the way it will run. REMOTE_VARS="$(cat </dev/null 2>&1 || { echo "missing on the host: \$tool" >&2; exit 1; } done [ -x "\${DENO}" ] || { echo "no deno at \${DENO}" >&2; exit 1; } [ -f "\${SOURCE_ROOT}/\${ENTRY_CONFIG}" ] && [ -f "\${SOURCE_ROOT}/\${ENTRYPOINT}" ] || { echo "no socialweb-computer-kcp checkout at \${SOURCE_ROOT}" >&2; exit 1; } install -d -m 0755 "\${PREFIX}" cp "\${SOURCE_ROOT}/deno.json" "\${SOURCE_ROOT}/deno.lock" "\${PREFIX}/" for tree in ts socialweb-computer-k8s; do rsync -a --delete --exclude node_modules --exclude web/downloads "\${SOURCE_ROOT}/\${tree}/" "\${PREFIX}/\${tree}/" done # Refuse a tree whose accounts do not survive a restart. See the header. grep -q 'accountStorage' "\${PREFIX}/\${ENTRYPOINT}" || { echo "\${PREFIX}/\${ENTRYPOINT} keeps accounts in memory (no accountStorage)." >&2 echo "Accounts would vanish on every restart and every re-run of this deploy would" >&2 echo "mint a second did:plc for \${HANDLE}. Ship a tree that persists them." >&2 exit 1 } id -u "\${SERVICE_USER}" >/dev/null 2>&1 || \ useradd --system --create-home --home-dir "\${STATE_DIR}" --shell /usr/sbin/nologin "\${SERVICE_USER}" install -d -m 0700 -o "\${SERVICE_USER}" -g "\${SERVICE_USER}" "\${STATE_DIR}" # The PDS key is the issuer of every access token it mints, and the admin # password gates createAccount (without it the route is unauthenticated). Both # are generated once and kept: regenerating either invalidates sessions or # reopens account creation. if [ ! -f "\${ENV_FILE}" ]; then umask 077 { printf 'PDS_PRIVATE_KEY_HEX=%s\n' "\$(openssl rand -hex 32)" printf 'PDS_ADMIN_PASSWORD=%s\n' "\$(openssl rand -base64 24 | tr -d '/+=' | cut -c1-24)" printf 'PDS_STORAGE_PATH=%s\n' "\${STATE_DIR}/pds.kv" } > "\${ENV_FILE}" echo "generated \${ENV_FILE}" fi chmod 0600 "\${ENV_FILE}" chown "\${SERVICE_USER}:\${SERVICE_USER}" "\${ENV_FILE}" # The CLI reads config.json from CONFIG_PATH_HONO_PDS. Keeping it in the state # dir leaves the shipped tree read-only. if [ ! -f "\${CONFIG_PATH}" ]; then printf '{\n "crawlers": ""\n}\n' > "\${CONFIG_PATH}" fi chown "\${SERVICE_USER}:\${SERVICE_USER}" "\${CONFIG_PATH}" chmod 0644 "\${CONFIG_PATH}" # Deno updates the lockfile next to its config whenever the resolved # dependencies move, and the shipped tree is read-only -- that is the point of # ProtectSystem=strict, and on a host where the sibling copy of this tree is # writable deno would never have complained. So the lock lives in the state dir: # copied from the tree on first run, updated there afterwards, and pointed at # with --lock. Pinning survives; the tree stays read-only. if [ ! -f "\${STATE_DIR}/deno.lock" ]; then cp "\${PREFIX}/deno.lock" "\${STATE_DIR}/deno.lock" fi chown "\${SERVICE_USER}:\${SERVICE_USER}" "\${STATE_DIR}/deno.lock" chmod 0644 "\${STATE_DIR}/deno.lock" cat > "\${STATE_DIR}/\${SERVICE_NAME}.service.new" </dev/null 2>&1 || true changed=no if cmp -s "\${STATE_DIR}/\${SERVICE_NAME}.service.new" "/etc/systemd/system/\${SERVICE_NAME}.service"; then rm -f "\${STATE_DIR}/\${SERVICE_NAME}.service.new" else mv "\${STATE_DIR}/\${SERVICE_NAME}.service.new" "/etc/systemd/system/\${SERVICE_NAME}.service" chmod 644 "/etc/systemd/system/\${SERVICE_NAME}.service" changed=yes fi systemctl daemon-reload systemctl enable "\${SERVICE_NAME}" >/dev/null if [ "\${changed}" = yes ]; then systemctl restart "\${SERVICE_NAME}" echo "unit changed; restarted \${SERVICE_NAME}" elif ! systemctl is-active --quiet "\${SERVICE_NAME}"; then systemctl start "\${SERVICE_NAME}" echo "unit unchanged; started \${SERVICE_NAME}" else echo "unit unchanged and running; left alone" fi for _ in \$(seq 1 60); do if curl -fsS "http://\${BIND}:\${PORT}/xrpc/_health" >/dev/null 2>&1; then echo "health on \${BIND}:\${PORT} ok" exit 0 fi sleep 2 done echo "\${SERVICE_NAME} never answered on \${BIND}:\${PORT}; last log lines:" >&2 journalctl -u "\${SERVICE_NAME}" -n 40 --no-pager >&2 || true exit 1 REMOTE # ── Caddy: the origin, then the account ─────────────────────────────────────── say "pointing ${ORIGIN} at the PDS and reloading Caddy" remote_script "caddy site for ${ORIGIN}" < "\${CADDY_SITES_DIR}/\${ORIGIN}.caddy" <> "\${CADDYFILE}" fi if ! grep -qE '^[[:space:]]*skip_install_trust[[:space:]]*\$' "\${CADDYFILE}"; then cp "\${CADDYFILE}" "\${CADDYFILE}.bak.\$(date +%s).trust" if [ "\$(grep -vE '^[[:space:]]*(#|\$)' "\${CADDYFILE}" | awk 'NR==1' | tr -d '[:space:]')" = "{" ]; then awk '!done && /^[[:space:]]*\{[[:space:]]*\$/ { print; print "\tskip_install_trust"; done = 1; next } { print }' "\${CADDYFILE}" > "\${CADDYFILE}.trust" else { printf '{\n\tskip_install_trust\n}\n\n'; cat "\${CADDYFILE}"; } > "\${CADDYFILE}.trust" fi mv "\${CADDYFILE}.trust" "\${CADDYFILE}" fi caddy validate --config "\${CADDYFILE}" >/dev/null systemctl reload caddy || systemctl restart caddy for _ in \$(seq 1 45); do if curl -fsS "https://\${ORIGIN}/xrpc/_health" >/dev/null 2>&1; then echo "https://\${ORIGIN} answers (certificate obtained)" exit 0 fi sleep 2 done echo "https://\${ORIGIN} did not answer within 90s" >&2 journalctl -u caddy -n 30 --no-pager >&2 || true exit 1 REMOTE # ── The account ─────────────────────────────────────────────────────────────── say "creating the test account if it is not already there" remote_script "create account ${HANDLE}" </dev/null || true)" did="\$(printf '%s' "\${session}" | jq -r '.did // empty')" if [ -z "\${did}" ]; then if [ -f "\${DID_FILE}" ]; then echo "an account for \${HANDLE} was already created on this PDS but the stored" >&2 echo "password no longer authenticates it. Refusing to create a second account" >&2 echo "for one handle. To start over: systemctl stop \${SERVICE_NAME}, remove" >&2 echo "\${DID_FILE}, \${PASSWORD_FILE} and \${STATE_DIR}/pds.kv, then re-run." >&2 exit 1 fi created="\$(curl -fsS -X POST "https://\${ORIGIN}/xrpc/com.atproto.server.createAccount" \\ -H "authorization: Basic \${ADMIN_B64}" \\ -H 'content-type: application/json' \\ --data "{\\"handle\\":\\"\${HANDLE}\\",\\"email\\":\\"\${ACCOUNT_EMAIL}\\",\\"password\\":\\"\${PW}\\"}")" did="\$(printf '%s' "\${created}" | jq -r '.did')" echo "created \${HANDLE} as \${did}" else echo "\${HANDLE} already exists as \${did}" fi printf '%s' "\${did}" > "\${DID_FILE}" chmod 0644 "\${DID_FILE}" echo "password is in \${PASSWORD_FILE} (0600), not printed here" REMOTE # ── The handle resolves without a DNS record ────────────────────────────────── say "serving the handle's did at https://${HANDLE}/.well-known/atproto-did" remote_script "caddy site for ${HANDLE}" < "\${CADDY_SITES_DIR}/\${HANDLE}.caddy" </dev/null systemctl reload caddy || systemctl restart caddy resolved='' for _ in \$(seq 1 45); do resolved="\$(curl -fsS "https://\${HANDLE}/.well-known/atproto-did" 2>/dev/null || true)" [ "\${resolved}" = "\$(cat \${DID_FILE})" ] && { echo "handle resolves to \${resolved}"; exit 0; } sleep 2 done echo "warning: https://\${HANDLE}/.well-known/atproto-did did not come up." >&2 echo "The PDS itself is up; a client that resolves \${HANDLE} by DNS still needs the" >&2 echo "_atproto TXT record printed at the end of this deploy." >&2 exit 0 REMOTE # ── Prove it: a session, and a write into a collection no public PDS takes ──── say "verifying: describeServer, session, createRecord" remote_script "verify ${SERVICE_NAME}" <&2; exit 1; } echo "session created" echo "--- POST putRecord into \${PROBE_COLLECTION}" record="{\\"repo\\":\\"self\\",\\"collection\\":\\"\${PROBE_COLLECTION}\\",\\"rkey\\":\\"deploy-check\\",\\"record\\":{\\"\\\$type\\":\\"\${PROBE_COLLECTION}\\",\\"note\\":\\"pds2 deploy probe\\",\\"createdAt\\":\\"\$(date -u +%Y-%m-%dT%H:%M:%S.000Z)\\"}}" curl -fsS -X POST "https://\${ORIGIN}/xrpc/com.atproto.repo.putRecord" \\ -H "authorization: Bearer \${token}" \\ -H 'content-type: application/json' \\ --data "\${record}"; echo echo "--- GET listRecords \${PROBE_COLLECTION} for \$(cat \${DID_FILE})" # Not repo=self: this PDS resolves "self" on the write routes only, so a read # has to name the account the way a client would. curl -fsS "https://\${ORIGIN}/xrpc/com.atproto.repo.listRecords?repo=\$(cat \${DID_FILE})&collection=\${PROBE_COLLECTION}" \\ -H "authorization: Bearer \${token}"; echo REMOTE # ── What an operator still has to know ──────────────────────────────────────── say "report" remote_script "report" <<'REMOTE' set -euo pipefail echo "service ${SERVICE_NAME}.service ($(systemctl is-active ${SERVICE_NAME}))" echo "listening ${BIND}:${PORT} (loopback only)" echo "origin https://${ORIGIN}" echo "handle ${HANDLE}" echo "account $(cat ${DID_FILE})" echo "password ${PASSWORD_FILE} (0600, root; never printed)" echo "state ${STATE_DIR} (deno dir, config, pds.env, pds.kv, account-did)" echo "code ${PREFIX} (the ${ENTRY_CONFIG} workspace, copied from ${SOURCE_ROOT})" echo echo "verify:" echo " curl -fsS https://${ORIGIN}/xrpc/_health" echo " curl -fsS https://${ORIGIN}/xrpc/com.atproto.server.describeServer" echo " curl -fsS https://${HANDLE}/.well-known/atproto-did" echo echo "no DNS record is needed: https://${HANDLE}/.well-known/atproto-did answers" echo "with the account's did, which is one of the two ways a client resolves a" echo "handle. The DNS method is the other, and it is the one that still works if" echo "that Caddy site block is ever removed -- add this record to take it:" echo echo " _atproto.${HANDLE}. IN TXT \"did=$(cat ${DID_FILE})\"" echo echo "sign in at https://k8s.socialweb.computer with the handle above; the password" echo "is in ${PASSWORD_FILE} on this host." REMOTE say "done"