From 88fe6997c4638db2172ce6cdf03f70a6bb52d0fc Mon Sep 17 00:00:00 2001 From: Tom Scanlan Date: Sat, 17 Oct 2026 06:50:10 -0400 Subject: [PATCH] https-app: an app can claim a handle ending, so its handles resolve through nginx An app that hands out handles of its own, as a PDS does, had no way to have them resolve: the leaf's *. covers one label, and nginx sent .. to the alpha, which answered "User not found". A third word to https-app.sh claims an ending: nginx gets a server for *.. beside the app's upstream, Host passed through, and the leaf is reissued from the same CA with *... https-up.sh reissues the leaf whenever it lacks any name the scripts expect, and the leaf's names live in one place, https-lib.sh. The PDSes' own endings and an ending another app holds are refused. --- README.md | 22 ++++++++++++--- https/nginx.conf.template | 4 ++- scripts/https-app.sh | 57 ++++++++++++++++++++++++++++++--------- scripts/https-ca.sh | 14 +++++----- scripts/https-lib.sh | 46 +++++++++++++++++++++++++++---- scripts/https-run | 2 +- scripts/https-up.sh | 6 ++--- 7 files changed, 118 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 59695e5..e7c999e 100644 --- a/README.md +++ b/README.md @@ -600,6 +600,7 @@ runs against it with no devnet code: | `prod.devnet.test`, `*.prod.devnet.test` | `pds-prod`, the build you run in prod, and its handles | | `plc.directory` | this stack's own PLC | | `.devnet.internal` | an app on this machine, at the port `scripts/https-app.sh` recorded for `` | +| `*..devnet.test` | the app that claimed `` with `scripts/https-app.sh`, for the handles it hands out | nginx answers all of them on `127.0.0.1:443` with one certificate from a local CA. Each PDS's public URL, OAuth issuer and DID document endpoint is `https://`, and the relay crawls @@ -753,6 +754,19 @@ metadata and keys from there over TLS. A client_id under `.test` is refused by t (`invalid_client_id: The client_id's TLD must not be a local hostname`), which is why apps live under `.devnet.internal`. +**A handle ending**, for an app that hands out handles of its own, as a PDS does. A third word +claims one: + +```bash +scripts/https-app.sh grouphost 7070 gh # also https://.gh.devnet.test -> port 7070 +``` + +nginx then sends every name under `gh.devnet.test` to the app, Host and all, so the app answers +`/.well-known/atproto-did` for its handles and they resolve from anywhere on the devnet. The leaf +gains `*.gh.devnet.test` from the same CA, so nothing has to be trusted again. `alpha`, `regular` and +`prod` belong to the PDSes, and an ending belongs to one app. A rerun without the ending drops it. +Under `https-run`, list each such handle in `HTTPS_RUN_EXTRA_NAMES`, as for any handle. + `scripts/https-probe.mjs` checks the whole path for any app name. It resolves an account through `https://plc.directory` with stock atcute at its defaults, serves a confidential client at `https://.devnet.internal/`, pushes a PAR with that client_id, and pushes a raw PAR with a @@ -803,9 +817,11 @@ REFUSED .test client_id: HTTP 400 invalid_client_id: The client_id's TLD must no `scripts/https-ca.sh` makes the CA once. `https-up.sh` runs it when there is no leaf, or when the leaf does not name `*.devnet.internal`. Rerunning it keeps the CA and only reissues the leaf, so a browser or OS that trusts `ca.crt` keeps trusting the devnet. The leaf names the three PDSes, -`*.devnet.test`, `*.regular.devnet.test`, `*.prod.devnet.test`, `plc.directory` and -`*.devnet.internal`; a TLS wildcard covers one label, so each handle domain needs its own. Add -deeper names with `HTTPS_EXTRA_NAMES="x.mine.devnet.test"`, then restart nginx. A name one label +`*.devnet.test`, `*.regular.devnet.test`, `*.prod.devnet.test`, `plc.directory`, +`*.devnet.internal`, and `*..devnet.test` for each handle ending an app claimed; a TLS +wildcard covers one label, so each handle domain needs its own. `https-app.sh` reissues the leaf +when it claims an ending, and `https-up.sh` when the leaf lacks any of these names. Add deeper names +with `HTTPS_EXTRA_NAMES="x.mine.devnet.test"` on every run, then restart nginx. A name one label under `devnet.test` is already covered. To start over, delete `data/https/ca.*` and trust the new `ca.crt` wherever the old one was trusted. diff --git a/https/nginx.conf.template b/https/nginx.conf.template index bf8540e..1b6b101 100644 --- a/https/nginx.conf.template +++ b/https/nginx.conf.template @@ -82,7 +82,9 @@ server { # data/https/nginx/ (mounted at /etc/nginx/devnet/), one upstream per app, naming # host.docker.internal, and one map line per name. An upstream's name is resolved # once, when nginx loads it, through the container's hosts file (extra_hosts), which -# Docker's DNS does not serve. Neither file has to exist. +# Docker's DNS does not serve. Neither file has to exist. An app that claimed a handle +# ending (scripts/https-app.sh ) also gets a server there for +# *..devnet.test, which wins over the alpha's *.devnet.test as the longer wildcard. include /etc/nginx/devnet/*.conf; map $host $devnet_app { diff --git a/scripts/https-app.sh b/scripts/https-app.sh index 15ff63f..869f18a 100755 --- a/scripts/https-app.sh +++ b/scripts/https-app.sh @@ -5,16 +5,23 @@ set -eu # devnet's nginx, for any app: its pages, its OAuth client metadata and its keys, at an # https origin the devnet's PDSes and https-run's processes both reach. # -# Usage: ./scripts/https-app.sh -# one lowercase DNS label; the app is https://.devnet.internal -# where the app listens on this machine. Listen on the address Docker's host -# gateway lands on (host.docker.internal; the docker0 address here), or on -# 0.0.0.0, so nginx can reach it. +# Usage: ./scripts/https-app.sh [] +# one lowercase DNS label; the app is https://.devnet.internal +# where the app listens on this machine. Listen on the address Docker's host +# gateway lands on (host.docker.internal; the docker0 address here), or on +# 0.0.0.0, so nginx can reach it. +# for an app that hands out handles of its own, as a PDS does: one lowercase +# DNS label. Every name under . (DEVNET_DOMAIN, devnet.test by +# default) goes to the app too, so a handle there resolves at the app. The +# leaf gains *.., from the same CA, so nothing has to be +# trusted again. alpha, regular and prod are the PDSes' own; an ending is one +# app's. # -# The route is kept in data/https/apps (a rerun with the same name changes its port); -# data/https/nginx/ is rendered from it, and nginx in DEVNET_PROJECT's stack is -# reloaded (DEVNET_PROJECT defaults to the one data/devnet.env names). If the stack is -# down, the route is kept and nginx loads it when https-up.sh starts it. +# The route is kept in data/https/apps (a rerun with the same name changes its port, and +# drops its ending unless given again); data/https/nginx/ is rendered from it, and nginx +# in DEVNET_PROJECT's stack is reloaded (DEVNET_PROJECT defaults to the one +# data/devnet.env names). If the stack is down, the route is kept and nginx loads it when +# https-up.sh starts it. # # A name with no route gets a 404 from nginx and reaches nothing. Inside https-run the # name resolves to 127.0.0.1; restart a process already running under it to pick up a @@ -25,18 +32,41 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" NAME="${1:-}" PORT="${2:-}" -if ! echo "${NAME}" | grep -qxE '[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?' || ! echo "${PORT}" | grep -qxE '[1-9][0-9]{0,4}' || [ "${PORT}" -gt 65535 ]; then - echo "Usage: https-app.sh (name: one lowercase DNS label)" >&2 +ENDING="${3:-}" +label() { echo "$1" | grep -qxE '[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?'; } +if ! label "${NAME}" || ! echo "${PORT}" | grep -qxE '[1-9][0-9]{0,4}' || [ "${PORT}" -gt 65535 ] \ + || { [ -n "${ENDING}" ] && ! label "${ENDING}"; } || [ $# -gt 3 ]; then + echo "Usage: https-app.sh [] (name, ending: one lowercase DNS label each)" >&2 exit 2 fi mkdir -p "${HTTPS_DIR}" APPS="${HTTPS_DIR}/apps" touch "${APPS}" -{ awk -v n="${NAME}" '$1 != n' "${APPS}"; echo "${NAME} ${PORT}"; } > "${APPS}.tmp" +if [ -n "${ENDING}" ]; then + case "${ENDING}" in + alpha | regular | prod) + echo "https-app: ${ENDING}.${DEVNET_DOMAIN} is a PDS's own" >&2 + exit 1 + ;; + esac + OWNER="$(awk -v n="${NAME}" -v e="${ENDING}" '$1 != n && $3 == e { print $1; exit }' "${APPS}")" + if [ -n "${OWNER}" ]; then + echo "https-app: handles under ${ENDING}.${DEVNET_DOMAIN} already go to ${OWNER} (data/https/apps)" >&2 + exit 1 + fi +fi +{ awk -v n="${NAME}" '$1 != n' "${APPS}"; echo "${NAME} ${PORT}${ENDING:+ ${ENDING}}"; } > "${APPS}.tmp" mv "${APPS}.tmp" "${APPS}" +# The CA is kept, and a new leaf is written over the old file, which nginx's mount follows. +NEW_CERTS="" +if leaf_lacks; then + "${SCRIPT_DIR}/https-ca.sh" + NEW_CERTS=1 +fi render_apps echo "https://${NAME}.devnet.internal -> port ${PORT} on this machine (data/https/apps)" >&2 +[ -z "${ENDING}" ] || echo "https://.${ENDING}.${DEVNET_DOMAIN} -> the same port" >&2 DEVNET_PROJECT="${DEVNET_PROJECT:-$(env_get DEVNET_PROJECT "${DATA_DIR}/devnet.env")}" if [ -z "${DEVNET_PROJECT}" ]; then @@ -51,5 +81,6 @@ if ! dc exec -T nginx nginx -t -q; then echo "https-app: nginx refused the new routes; data/https/nginx/ holds what it read" >&2 exit 1 fi +# A reload rereads the certificate too. dc exec -T nginx nginx -s reload -echo "Reloaded nginx in ${DEVNET_PROJECT}" >&2 +echo "Reloaded nginx in ${DEVNET_PROJECT}${NEW_CERTS:+, with the new leaf}" >&2 diff --git a/scripts/https-ca.sh b/scripts/https-ca.sh index 87697c3..e8e0545 100755 --- a/scripts/https-ca.sh +++ b/scripts/https-ca.sh @@ -2,9 +2,10 @@ set -eu # The https devnet's certificates: one local CA, made once, and one leaf that nginx -# serves for every devnet name: the three PDSes and their handles, plc.directory, and -# every app at .devnet.internal. The PDS names sit under DEVNET_DOMAIN -# (devnet.test by default; scripts/https-lib.sh), and the names below use the default. +# serves for every devnet name: the three PDSes and their handles, plc.directory, every +# app at .devnet.internal, and the handles under each ending an app claimed with +# scripts/https-app.sh. The PDS names sit under DEVNET_DOMAIN (devnet.test by default; +# scripts/https-lib.sh, whose leaf_names lists them), and the names below use the default. # # Usage: ./scripts/https-ca.sh # @@ -20,20 +21,19 @@ set -eu # a new DEVNET_DOMAIN, means a new CA, trusted again. # # A TLS wildcard covers one label, so *.devnet.test does not cover a handle on the -# regular PDS (.regular.devnet.test); each PDS's handle domain has its own. +# regular PDS (.regular.devnet.test); each handle domain has its own. # # Nothing here prints a key. Pass extra leaf names with HTTPS_EXTRA_NAMES -# (space-separated), for instance when a PDS is added. +# (space-separated), for instance when a PDS is added, on every run. SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" . "${SCRIPT_DIR}/https-lib.sh" D="${DEVNET_DOMAIN}" +NAMES="$(leaf_names | tr '\n' ' ')" OUT="${SCRIPT_DIR}/../data/https" mkdir -p "${OUT}" cd "${OUT}" -NAMES="alpha.${D} regular.${D} prod.${D} *.${D} *.regular.${D} *.prod.${D} plc.directory *.devnet.internal ${HTTPS_EXTRA_NAMES:-}" - umask 077 if [ -f ca.crt ] && [ -f ca.key ]; then diff --git a/scripts/https-lib.sh b/scripts/https-lib.sh index 17533f0..609ff78 100644 --- a/scripts/https-lib.sh +++ b/scripts/https-lib.sh @@ -109,9 +109,41 @@ add_name() { grep -qxF "$1" "${HTTPS_DIR}/names" || echo "$1" >> "${HTTPS_DIR}/names" } -# render_apps: nginx's app routes, from data/https/apps (lines of " "), into -# data/https/nginx/: one upstream per app at host.docker.internal: (apps.conf) and -# one map line per name (apps.map). See https/nginx.conf.template. +# app_handles: the handle endings apps claimed with scripts/https-app.sh, one per line. +app_handles() { + [ -f "${HTTPS_DIR}/apps" ] || return 0 + awk 'NF >= 3 { print $3 }' "${HTTPS_DIR}/apps" +} + +# leaf_names: every name the leaf carries, one or more per line. A TLS wildcard covers one +# label, so each handle domain has its own: the three PDSes', and *.. for +# each ending an app claimed. HTTPS_EXTRA_NAMES adds names (space-separated). +leaf_names() { + _d="${DEVNET_DOMAIN}" + echo "alpha.${_d} regular.${_d} prod.${_d} *.${_d} *.regular.${_d} *.prod.${_d} plc.directory *.devnet.internal" + app_handles | sed "s/.*/*.&.${_d}/" + echo "${HTTPS_EXTRA_NAMES:-}" +} + +# leaf_lacks: true when there is no leaf, or it lacks a name leaf_names lists. +leaf_lacks() { + [ -f "${HTTPS_DIR}/leaf.crt" ] && [ -f "${HTTPS_DIR}/leaf.key" ] || return 0 + _san="$(openssl x509 -in "${HTTPS_DIR}/leaf.crt" -noout -ext subjectAltName 2>/dev/null | tr -d ' ' | tr ',' '\n')" + set -f # a wildcard name is a name, not a glob + for _n in $(leaf_names); do + if ! printf '%s\n' "${_san}" | grep -qxF "DNS:${_n}"; then + set +f + return 0 + fi + done + set +f + return 1 +} + +# render_apps: nginx's app routes, from data/https/apps (lines of " +# []"), into data/https/nginx/: one upstream per app at host.docker.internal: +# and, for an app that claimed a handle ending, a server for *.. (apps.conf), +# and one map line per name (apps.map). See https/nginx.conf.template. render_apps() { mkdir -p "${HTTPS_DIR}/nginx" _apps="${HTTPS_DIR}/apps" @@ -119,13 +151,17 @@ render_apps() { { echo "# Written by scripts/https-app.sh from data/https/apps; rerun it instead of editing." if [ -f "${_apps}" ]; then - awk 'NF == 2 { u = $1; gsub("-", "_", u); printf "upstream devnet_app_%s { server host.docker.internal:%s; }\n", u, $2 }' "${_apps}" + awk 'NF >= 2 { u = $1; gsub("-", "_", u); printf "upstream devnet_app_%s { server host.docker.internal:%s; }\n", u, $2 }' "${_apps}" + # Host is passed through (the http block sets it), so the app answers + # /.well-known/atproto-did for each handle. nginx takes the longer of two matching + # wildcards, so these win over the alpha's *.. + awk -v d="${DEVNET_DOMAIN}" 'NF >= 3 { u = $1; gsub("-", "_", u); printf "\n# Handles under %s.%s go to %s.\nserver {\n listen 443 ssl;\n server_name *.%s.%s;\n location / {\n proxy_pass http://devnet_app_%s;\n }\n}\n", $3, d, $1, $3, d, u }' "${_apps}" fi } > "${_out}/apps.conf.tmp" { echo "# Written by scripts/https-app.sh from data/https/apps; rerun it instead of editing." if [ -f "${_apps}" ]; then - awk 'NF == 2 { u = $1; gsub("-", "_", u); printf "%s.devnet.internal devnet_app_%s;\n", $1, u }' "${_apps}" + awk 'NF >= 2 { u = $1; gsub("-", "_", u); printf "%s.devnet.internal devnet_app_%s;\n", $1, u }' "${_apps}" fi } > "${_out}/apps.map.tmp" mv "${_out}/apps.conf.tmp" "${_out}/apps.conf" diff --git a/scripts/https-run b/scripts/https-run index 23607c8..ccb64ec 100755 --- a/scripts/https-run +++ b/scripts/https-run @@ -46,7 +46,7 @@ if [ -f "${DATA}/names" ]; then NAMES="${NAMES} $(grep -E '^[A-Za-z0-9.-]+$' "${DATA}/names" | tr '\n' ' ')" fi if [ -f "${DATA}/apps" ]; then - NAMES="${NAMES} $(awk 'NF == 2 { printf "%s.devnet.internal ", $1 }' "${DATA}/apps")" + NAMES="${NAMES} $(awk 'NF >= 2 { printf "%s.devnet.internal ", $1 }' "${DATA}/apps")" fi NAMES="${NAMES} ${HTTPS_RUN_EXTRA_NAMES:-}" diff --git a/scripts/https-up.sh b/scripts/https-up.sh index 2957eb4..5e9c5ec 100755 --- a/scripts/https-up.sh +++ b/scripts/https-up.sh @@ -39,10 +39,10 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" mkdir -p "${HTTPS_DIR}/nginx" -# The leaf must name *.devnet.internal, which a leaf from before the app routes lacks. +# The leaf must carry every name leaf_names lists: a leaf from before the app routes lacks +# *.devnet.internal, and one from before an app claimed a handle ending lacks its wildcard. NEW_CERTS="" -if [ ! -f "${HTTPS_DIR}/leaf.crt" ] || [ ! -f "${HTTPS_DIR}/leaf.key" ] \ - || ! openssl x509 -in "${HTTPS_DIR}/leaf.crt" -noout -ext subjectAltName 2>/dev/null | grep -qF 'DNS:*.devnet.internal'; then +if leaf_lacks; then "${SCRIPT_DIR}/https-ca.sh" NEW_CERTS=1 fi