From 7b06fc3c4ad7b26f9dc015cae82e7faf83277e5d Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sun, 11 Oct 2026 19:32:54 +0200 Subject: [PATCH] refactor(dot-resolver): install the tracked sync-dot-cert.sh instead of generating it Every script under /usr/local/bin must be a repo file. install_cert_sync wrote /usr/local/bin/sync-dot-cert.sh from a heredoc with the domain, Caddy container and cert dir baked in. The script now lives at linux_configuration/scripts/sync-dot-cert.sh, takes those three as arguments (defaults = the installer's constants), and is installed with install -m 755. dot-cert-sync.service and the first sync pass the arguments, so --domain reaches the timer too. Test asserts the installed file is the tracked one byte for byte and runs the script itself against a docker shim (reload only on change). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014yhTkYV6ZeuAbQYvykFGFc Co-Authored-By: Claude Opus 5.5 (1M context) --- .../track-sync-dot-cert-2026-10-11.json | 15 ++++ ...rack-usr-local-bin-scripts-2026-10-11.json | 36 ++++++++++ .../features/lib/dot_resolver_install.sh | 30 +++----- .../lib/tests/test_dot_resolver_install.sh | 71 +++++++++++++++---- .../features/setup_dot_resolver.sh | 10 +-- linux_configuration/scripts/sync-dot-cert.sh | 28 ++++++++ 6 files changed, 151 insertions(+), 39 deletions(-) create mode 100644 docs/superpowers/contracts/track-sync-dot-cert-2026-10-11.json create mode 100644 docs/superpowers/evidence/track-usr-local-bin-scripts-2026-10-11.json create mode 100755 linux_configuration/scripts/sync-dot-cert.sh diff --git a/docs/superpowers/contracts/track-sync-dot-cert-2026-10-11.json b/docs/superpowers/contracts/track-sync-dot-cert-2026-10-11.json new file mode 100644 index 00000000..aba7a460 --- /dev/null +++ b/docs/superpowers/contracts/track-sync-dot-cert-2026-10-11.json @@ -0,0 +1,15 @@ +{ + "title": "Install the tracked sync-dot-cert.sh instead of generating it", + "objective": "setup_dot_resolver.sh installs linux_configuration/scripts/sync-dot-cert.sh to /usr/local/bin with install -m 755 rather than writing it from a heredoc, with the same runtime behaviour (same docker cp paths, reload stunnel only on change) for the default domain and correct behaviour for a non-default --domain.", + "acceptance_criteria": [ + "The installed /usr/local/bin/sync-dot-cert.sh is byte-identical to the tracked file and mode 755; a stale generated copy is replaced", + "dot-cert-sync.service ExecStart passes domain, Caddy container and cert dir", + "The tracked script assembles cert+key into a 600 combined.pem, reloads stunnel on change and is silent when unchanged", + "test_dot_resolver_install.sh passes inside shell_coverage_jail.sh and the change passes in vmbox" + ], + "out_of_scope": [ + "Rewriting the live host files (kuhy re-runs setup_dot_resolver.sh)", + "Tracking deluge-fix-lt.py (blocked by repo lint for a verbatim copy)" + ], + "verifier": "meta/scripts/shell_coverage_jail.sh --subject linux_configuration/features/lib/tests/test_dot_resolver_install.sh --min 0 --fail-on-case-error; vmbox guest run" +} diff --git a/docs/superpowers/evidence/track-usr-local-bin-scripts-2026-10-11.json b/docs/superpowers/evidence/track-usr-local-bin-scripts-2026-10-11.json new file mode 100644 index 00000000..a41aeccd --- /dev/null +++ b/docs/superpowers/evidence/track-usr-local-bin-scripts-2026-10-11.json @@ -0,0 +1,36 @@ +{ + "intent": "Every script installed to /usr/local/bin is a tracked repo file: sync-dot-cert.sh is installed from linux_configuration/scripts/ instead of being generated from a heredoc.", + "scope": [ + "linux_configuration/scripts/sync-dot-cert.sh, linux_configuration/features/setup_dot_resolver.sh, linux_configuration/features/lib/dot_resolver_install.sh, linux_configuration/features/lib/tests/test_dot_resolver_install.sh", + "Non-goal: stunnel config and units stay generated; deluge-fix-lt.py is not in this commit (repo lint blocks a verbatim copy)" + ], + "changes": [ + "scripts/sync-dot-cert.sh takes (defaults = the installer's) instead of baking them in; install_cert_sync does sudo install -m 755 of it and dies if it is missing", + "dot-cert-sync.service ExecStart and the installer's first sync pass the three arguments, so a non-default --domain reaches the timer; unused cert_source_dir removed" + ], + "verification": [ + { + "command": "shell_coverage_jail.sh --subject features/lib/tests/test_dot_resolver_install.sh --min 0 --fail-on-case-error", + "result": "pass", + "evidence": "no case errors; a mutated sync script (reload removed) makes the jail fail" + }, + { + "command": "vmbox guest cco: install_cert_sync + install_units as root over a stale generated copy, then the installed script against a docker shim", + "result": "pass", + "evidence": "installed == tracked (cmp), 755 root, ExecStart passes domain/container/dir, docker cp paths identical to the live script, second run silent. Passed in vmbox, not verified on the host" + }, + { + "command": "shellcheck -x on the four shell files", + "result": "pass", + "evidence": "clean" + } + ], + "risks": [ + "The live /usr/local/bin/sync-dot-cert.sh and dot-cert-sync.service stay the old generated ones until setup_dot_resolver.sh is re-run", + "A bare manual sync-dot-cert.sh run uses the defaults, which must track the installer's constants" + ], + "rollback": [ + "git revert the commit and re-run setup_dot_resolver.sh", + "Check systemctl start dot-cert-sync.service and setup_dot_resolver.sh --check" + ] +} diff --git a/linux_configuration/features/lib/dot_resolver_install.sh b/linux_configuration/features/lib/dot_resolver_install.sh index fbc0161a..2445685b 100755 --- a/linux_configuration/features/lib/dot_resolver_install.sh +++ b/linux_configuration/features/lib/dot_resolver_install.sh @@ -9,25 +9,17 @@ install_cert_sync() { # Caddy renews on its own schedule; stunnel reads files from disk and must # be told. A timer copies the current cert out of the container and # reloads stunnel only when the content actually changed. + # The script is the tracked scripts/sync-dot-cert.sh, installed as-is; the + # values it used to have baked in are now its arguments (sync_script_args). sudo install -d -m 700 "$CERT_DIR" - sudo tee "$SYNC_SCRIPT" >/dev/null <"\$tmp/combined.pem" -if ! cmp -s "\$tmp/combined.pem" "$CERT_DIR/combined.pem"; then - install -m 600 "\$tmp/combined.pem" "$CERT_DIR/combined.pem" - systemctl reload-or-restart stunnel-dot.service - echo "certificate updated for $DOMAIN" -fi -EOF - sudo chmod 755 "$SYNC_SCRIPT" + [[ -f $SYNC_SCRIPT_SRC ]] || die "tracked sync script missing: $SYNC_SCRIPT_SRC" + sudo install -m 755 "$SYNC_SCRIPT_SRC" "$SYNC_SCRIPT" +} + +# The arguments every caller of $SYNC_SCRIPT passes, so a non-default --domain +# reaches the timer's runs too. +sync_script_args() { + printf '%s %s %s' "$DOMAIN" "$CADDY_CONTAINER" "$CERT_DIR" } install_stunnel_conf() { @@ -74,7 +66,7 @@ Description=Copy the Caddy certificate for $DOMAIN to stunnel [Service] Type=oneshot -ExecStart=$SYNC_SCRIPT +ExecStart=$SYNC_SCRIPT $(sync_script_args) EOF sudo tee /etc/systemd/system/dot-cert-sync.timer >/dev/null <&2 + return 1 } # shellcheck source=../dot_resolver_install.sh @@ -64,9 +61,16 @@ mkdir -p /etc/stunnel /etc/systemd/system /usr/local/bin # --- install_cert_sync ------------------------------------------------------ install_cert_sync -_t_file_has "$SYNC_SCRIPT" 'combined.pem' "cert sync script assembles combined.pem" -_t_file_has "$SYNC_SCRIPT" "src=\"/data/caddy/certificates" "cert source path is baked in, not left empty" -_t_file_has "$SYNC_SCRIPT" 'reload-or-restart' "cert sync restarts stunnel after renewal" +if cmp -s "$SYNC_SCRIPT_SRC" "$SYNC_SCRIPT"; then + _t_pass "the installed sync script is the tracked file, byte for byte" +else + _t_fail "the installed sync script is the tracked file, byte for byte" +fi +if grep -q 'Generated by' "$SYNC_SCRIPT"; then + _t_fail "the sync script is not generated" +else + _t_pass "the sync script is not generated" +fi if [[ -d $CERT_DIR ]]; then _t_pass "cert dir created" else @@ -74,6 +78,46 @@ else fi _t_eq "700" "$(stat -c '%a' "$CERT_DIR")" "cert dir is mode 700 (it holds a private key)" _t_eq "755" "$(stat -c '%a' "$SYNC_SCRIPT")" "sync script is executable" +# Reinstall over a stale generated copy: install replaces it (idempotent). +printf '#!/bin/bash\n# Generated by setup_dot_resolver.sh\n' >"$SYNC_SCRIPT" +install_cert_sync +_t_eq "$(sha256sum <"$SYNC_SCRIPT_SRC")" "$(sha256sum <"$SYNC_SCRIPT")" "a stale generated copy is replaced" +out="$(SYNC_SCRIPT_SRC="$TEST_TMPDIR/absent.sh" install_cert_sync 2>&1 || true)" +_t_has "$out" 'tracked sync script missing' "a missing tracked file stops the install" + +# --- the tracked sync script itself ----------------------------------------- +# docker cp is stubbed to copy from a fixture tree, so the script's own logic +# (assemble, compare, install, reload-only-on-change) runs for real. +SYNC_CERTS="$TEST_TMPDIR/caddy" +mkdir -p "$SYNC_CERTS" +cat >"$TEST_TMPDIR/bin/docker" <>"$TEST_TMPDIR/calls.log" +cp "$SYNC_CERTS/\$(basename "\${2#*:}")" "\$3" +DOCKER +chmod +x "$TEST_TMPDIR/bin/docker" +_t_stub systemctl +sync_dir="$TEST_TMPDIR/stunnel-dot" +mkdir -p "$sync_dir" +printf 'CERT1\n' >"$SYNC_CERTS/d.example.crt" +printf 'KEY1\n' >"$SYNC_CERTS/d.example.key" +out="$("$SYNC_SCRIPT_SRC" d.example box "$sync_dir")" +_t_eq "$(printf 'CERT1\nKEY1')" "$(cat "$sync_dir/combined.pem")" "the sync assembles cert + key" +_t_eq "600" "$(stat -c '%a' "$sync_dir/combined.pem")" "combined.pem is mode 600" +_t_has "$out" 'certificate updated for d.example' "a new cert is announced" +_t_called 'docker cp box:/data/caddy/certificates/acme-v02.api.letsencrypt.org-directory/d.example/d.example.crt' \ + "the cert is read from the domain's Caddy dir in the named container" +_t_called 'systemctl reload-or-restart stunnel-dot.service' "a new cert reloads stunnel" +: >"$TEST_TMPDIR/calls.log" +out="$("$SYNC_SCRIPT_SRC" d.example box "$sync_dir")" +_t_eq "" "$out" "an unchanged cert is silent" +if grep -q reload "$TEST_TMPDIR/calls.log"; then + _t_fail "an unchanged cert does not reload stunnel" +else + _t_pass "an unchanged cert does not reload stunnel" +fi +_t_unstub docker +_t_unstub systemctl # --- install_stunnel_conf --------------------------------------------------- install_stunnel_conf @@ -94,7 +138,8 @@ if grep -q '^Requires=dnsmasq' /etc/systemd/system/stunnel-dot.service; then else _t_pass "no Requires=dnsmasq.service" fi -_t_file_has /etc/systemd/system/dot-cert-sync.service "$SYNC_SCRIPT" "cert-sync service runs the sync script" +_t_file_has /etc/systemd/system/dot-cert-sync.service "^ExecStart=$SYNC_SCRIPT $DOMAIN $CADDY_CONTAINER $CERT_DIR\$" \ + "cert-sync service passes domain, container and cert dir" _t_file_has /etc/systemd/system/dot-cert-sync.timer 'OnCalendar' "cert-sync timer is scheduled" _t_called 'daemon-reload' "units are followed by a daemon-reload" diff --git a/linux_configuration/features/setup_dot_resolver.sh b/linux_configuration/features/setup_dot_resolver.sh index 3ba9050c..bdd91c05 100755 --- a/linux_configuration/features/setup_dot_resolver.sh +++ b/linux_configuration/features/setup_dot_resolver.sh @@ -77,12 +77,6 @@ require_tools() { fi } -cert_source_dir() { - # Caddy stores certs per-domain under its data volume. - printf '/data/caddy/certificates/acme-v02.api.letsencrypt.org-directory/%s' \ - "$DOMAIN" -} - verify_prerequisites() { # A DoT endpoint in front of a resolver that is not running would answer # every query with a connection error, which looks exactly like a network @@ -110,6 +104,8 @@ verify_prerequisites() { } SCRIPT_DIR="$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" +# Installed verbatim to $SYNC_SCRIPT; never generated (see install_cert_sync). +readonly SYNC_SCRIPT_SRC="$SCRIPT_DIR/../scripts/sync-dot-cert.sh" # shellcheck source=lib/dot_resolver_install.sh source "$SCRIPT_DIR/lib/dot_resolver_install.sh" @@ -126,7 +122,7 @@ main() { install_units log "Syncing certificate..." - sudo "$SYNC_SCRIPT" || die "certificate sync failed - has Caddy issued $DOMAIN yet?" + sudo "$SYNC_SCRIPT" "$DOMAIN" "$CADDY_CONTAINER" "$CERT_DIR" || die "certificate sync failed - has Caddy issued $DOMAIN yet?" sudo systemctl enable --now stunnel-dot.service sudo systemctl enable --now dot-cert-sync.timer diff --git a/linux_configuration/scripts/sync-dot-cert.sh b/linux_configuration/scripts/sync-dot-cert.sh new file mode 100755 index 00000000..6bb3c5ba --- /dev/null +++ b/linux_configuration/scripts/sync-dot-cert.sh @@ -0,0 +1,28 @@ +#!/bin/bash +# Copy the Caddy-managed certificate for the DoT hostname out to stunnel, and +# reload stunnel only when the content actually changed. +# +# Installed to /usr/local/bin/sync-dot-cert.sh by +# features/setup_dot_resolver.sh (install -m 755 of THIS file) and run daily by +# dot-cert-sync.timer. The installer passes all three arguments, so the +# defaults below only matter for a bare manual run; they match the installer's. +# +# Usage: sync-dot-cert.sh [ [ []]] +set -euo pipefail + +domain="${1:-dns.kuhy.duckdns.org}" +container="${2:-gitea-caddy}" +cert_dir="${3:-/etc/stunnel/dot}" + +# Caddy stores certs per-domain under its data volume. +src="/data/caddy/certificates/acme-v02.api.letsencrypt.org-directory/$domain" +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +docker cp "$container:$src/$domain.crt" "$tmp/cert.pem" +docker cp "$container:$src/$domain.key" "$tmp/key.pem" +cat "$tmp/cert.pem" "$tmp/key.pem" >"$tmp/combined.pem" +if ! cmp -s "$tmp/combined.pem" "$cert_dir/combined.pem"; then + install -m 600 "$tmp/combined.pem" "$cert_dir/combined.pem" + systemctl reload-or-restart stunnel-dot.service + echo "certificate updated for $domain" +fi