refactor(dot-resolver): install the tracked sync-dot-cert.sh instead of generating it
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (mypy, mypy) (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (pylint, pylint) (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (rest, ) (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (shellcheck, shellcheck) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / lib-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014yhTkYV6ZeuAbQYvykFGFc

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-10-11 19:32:54 +02:00
parent 2dc4081dc9
commit 7b06fc3c4a
6 changed files with 151 additions and 39 deletions

View File

@ -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 <jail_args> --min 0 --fail-on-case-error; vmbox guest run"
}

View File

@ -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 <domain> <caddy-container> <cert-dir> (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 <jail_args> --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"
]
}

View File

@ -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 <<EOF
#!/bin/bash
# Copy the Caddy-managed certificate for $DOMAIN out to stunnel.
# Generated by $SCRIPT_NAME - edit that instead.
set -euo pipefail
src="$(cert_source_dir)"
tmp="\$(mktemp -d)"
trap 'rm -rf "\$tmp"' EXIT
docker cp "$CADDY_CONTAINER:\$src/$DOMAIN.crt" "\$tmp/cert.pem"
docker cp "$CADDY_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
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 <<EOF

View File

@ -38,15 +38,12 @@ DOMAIN="dns.kuhy.duckdns.org"
BIND_ADDR="10.8.0.1"
CADDY_CONTAINER="caddy"
# install_cert_sync interpolates this at GENERATION time (the heredoc is
# unquoted), baking the resolved path into the script it writes. That is
# deliberate, not a bug -- the live /usr/local/bin/sync-dot-cert.sh carries a
# fully populated src=. The fixture must therefore define it exactly as the
# entry script does, or the generated script gets src="" and the assertion
# below silently passes over an empty path.
cert_source_dir() {
printf '/data/caddy/certificates/acme-v02.api.letsencrypt.org-directory/%s' \
"$DOMAIN"
# install_cert_sync installs this tracked file verbatim (no heredoc): every
# script under /usr/local/bin must be a file in a repo.
SYNC_SCRIPT_SRC="$(cd "$SCRIPT_DIR/../../../scripts" && pwd)/sync-dot-cert.sh"
die() {
printf 'die: %s\n' "$1" >&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" <<DOCKER
#!/usr/bin/env bash
printf 'docker %s\n' "\$*" >>"$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"

View File

@ -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

View File

@ -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 [<domain> [<caddy-container> [<cert-dir>]]]
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