Skip to content

Round-3 audit: 28 contained fixes (TLS roots, lock guards, staging, env recovery, process groups) - #4

Merged
im-tyler merged 9 commits into
mainfrom
audit/round3
Sep 19, 2026
Merged

im-tyler merged 9 commits into
mainfrom
audit/round3

Conversation

@im-tyler

Copy link
Copy Markdown
Contributor

28 of 52 findings fixed with regression tests; 24 standing deferrals with round-3 evidence folded in. Race suite green.

…tion-window prune, fail-closed pins

A01: attempt TLS paths move from the flat /deployments/caddy/tls/att root
to /deployments/caddy/tls/att/<app>/<hash>.<id>/ (container-side
/etc/caddy/tls/att/<app>/…). PruneAttempts sweeps only the pruning app's
artifact root and TLS root; the legacy flat root is never swept — a flat
entry's owning app cannot be proven, and sweeping it with one app's keep
set deleted OTHER apps' live certificates whenever hashes differed (two
apps can even share a hash string).

A02: the attempt-prune protection window widens from current+previous+pins
to also cover every release that still has containers on the server —
keep_versions retention holds releases beyond current+previous whose
records and Caddy routes reference attempt-scoped TLS/env bytes. A version
leaving the keep window loses containers at step 15b of the same deploy,
so its artifacts first prunable on the next.

A03: an unreadable pin file now SKIPS attempt pruning entirely, matching
version pruning's fail-closed policy, instead of pruning with
current+previous protection only.

NewAttempt also validates the app name against the config grammar (A17
boundary piece): attempt paths interpolate the app into host and
container-side directories.
… staging

A04: ReleaseLockFenced runs the release under the holdership guard when a
fence handle is present — grep owner info, then rm -rf, in one shell
invocation. After a takeover the stale holder's deferred release is
refused (ErrFenceLost treated as success: the lock belongs to someone
else and leaving it alone IS the correct outcome) instead of deleting the
successor's lock and admitting a third operation. A nil handle keeps the
historical unfenced release (admin unlock, pre-F16 paths). A handle for a
different app is refused outright (A17 lease-correspondence rule, also
enforced in WriteFenced).

A06: WriteFenced and the lock renewal stage to unique owner-scoped
siblings (state.json.tmp-<owner>-<nonce>) instead of the fixed
state.json.tmp-fence / .lock/info.renew names shared by every generation,
so a stale holder can no longer upload into the successor's staging path
and ride its guarded rename into authority. Renewal staging also moves
into the app directory: a stale renewal whose .lock was already removed
can no longer recreate the directory (Upload's mkdir -p) and ghost-lock
the app for a full staleLockTTL.

MockExecutor now models rm -rf -- as a recursive removal of recorded
files, so the guarded release is provable against recorded state.
…blish-branch route restore, detached compensation, honest recovery reporting, logged image

A08: the same-version path no longer force-removes a RUNNING _replaced
container — after a failed attempt renamed the serving predecessor, a
retry used to delete it before any healthy replacement existed, taking
the app down. A running _replaced is refused with recovery direction; a
stopped corpse is still cleared. Rename failures are no longer swallowed:
an unclassified failure with the source container still present aborts
the deploy instead of leaving snapshot and candidate names disagreed.

A10: abortStateCommit's fixed-port branch (host ingress OR any publish
entry) restores the Caddy route for caddy-ingress apps before removing
the candidates — a caddy+publish app's commit failure used to return
from the branch with Caddy still pointing at the removed candidate
names. On route-restore failure the candidates are restarted instead of
routing to nothing.

A11: abortStateCommit runs every compensation (stops, restarts, route
restore, log) on a detached bounded recovery context — a commit failure
caused by a cancelled deploy context used to skip them via the dead ctx.

A13: restoreDisplacedAndStarted reports every failed stop/remove/restart
(itemized in output and in the returned error) — 'restored' was true
whenever zero candidates had started, and one successful restart of a
multi-container recovery read as full recovery.

A26: logDeploy populates LogEntry.Image (the open half of TCL-19).
…-scoped assets, plan validation, default port, image pinning, worker readiness, partial-run reconcile

A15: asset bridging builds THIS attempt's private asset tree under
meta/att/<hash>.<id>/assets (seeded from the previous attempt's tree with
a real cp -a — hardlink seeding would let a later write truncate shared
inodes) instead of copying into the shared /deployments/<app>/assets the
running release still reads; a failed candidate can no longer mutate the
live app's files. Extraction switches from 'docker run IMAGE sh -c cp'
(image ENTRYPOINTs could wrap/replace it) to docker create + docker cp,
and the volumes map is cloned before the mount is added, so the caller's
map is no longer mutated through the Config copy.

A52: DeployFenced resolves the immutable image ID once and creates every
web replica and worker from it — a mutable tag re-pointed by a concurrent
pull/build/tag between creates can no longer mix images within a release
(the requested ref stays the recorded provenance; resolution failure warns
and falls back to the requested reference).

A17: Config.validate now checks identity grammar (app via config.
ValidateName, version, process names), rejects unknown ingress modes, and
rejects publish+replicas>1 (fixed ports cannot be load balanced).
releasemeta.Path validates the app against the config grammar (was:
non-empty only). SplitHostPort rejects ports outside 1..65535.

A18: ContainerPort == 0 normalizes to 80 once at the top of DeployFenced
and the normalized value drives host ports, Caddy upstreams, the
diagnosis, and every create — a ':0' upstream can no longer be rendered.

A23: workers must still be running (not exited/dead/restarting/unhealthy)
one second after their detached run, or the deploy fails with full
cleanup; an unreadable state inspect degrades to a warning.

A14: a failed docker run reconciles the candidate name — a created-but-
unstarted corpse is removed so the next deploy cannot collide; a RUNNING
container under the name is never touched.
…s, ambiguity-refusing port inspectors, honest prune accounting

A19: the primary -p binding is built by publishBinding (net.JoinHostPort,
IP-validated, both port-ranged) and quoted like every other interpolated
argument — a bare IPv6 bind such as ::1 used to concatenate into an
ambiguous '::1:49152:80' and was the run command's only unquoted value.

A21: HostPort and InternalPort refuse containers with multiple DISTINCT
ports instead of taking the first field (publish: entries and multi-EXPOSE
images make those real; a wrong pick probed an auxiliary listener or
routed Caddy at one); HostBindIP reports '' on mixed binds. The release
record's TCL-14 primary designation remains the authority these fallbacks
defer to.

A25: PruneVersions counts a version as pruned only when every container
removal succeeded and returns the joined failures; the deploy caller
reports partial cleanup ('version prune incomplete') instead of printing
nothing or claiming failed removals as pruned.
…lation, derived provisioning identity

A27: LocalExecutor.Upload mirrors RemoteExecutor.Upload's contract — the
resident autodeploy path runs on this executor, so the remote hardening
did not cover it. The write lands in a private sibling temp, is chmod'd
and fsync'd BEFORE publication, and is renamed over the destination
(replacing a leaf symlink itself, never following it); a failed or
cancelled upload leaves the previous contents intact and no staging
siblings behind.

A28: local commands run in their own process group with Cancel killing
the whole group (SIGKILL) and WaitDelay bounding the post-cancellation
wait — exec.CommandContext's default killed only the shell, leaving
descendants running with stdout/stderr open, which blocked
CombinedOutput/Wait indefinitely on the resident engine and kept mutated
state behind a released lease. Build-tagged: unix sets the group; the
non-unix fallback kills the shell directly and is explicit about the
weaker guarantee.

A32: new PublicKeyBytes derives the provisioning public key FROM the
requested private identity and verifies any existing .pub against it;
setup uses it. PublicKeyPath no longer falls through to unrelated default
.pub files for an explicit key (a key without .pub is an error pointing
at derivation). The x/crypto/ssh import in remote.go is aliased (gossh)
for the explicit references this adds.
…n AOF gate, post-stop snapshot + start compensation, collision-proof backup ids, unsigned cron grammar at the sink

A39: the .env commit is a set -eu script staging BOTH files as private
mktemp siblings on the destination filesystem — the old copy's 'cp -p'
ran inside an if without chaining, so its failure was skipped and the
destructive mv ran anyway, and the new env was published by a
cross-filesystem mv from /tmp chmod'd only after it was live.

A40: the redis restore's AOF preflight is a Go-level check requiring a
proven 'appendonly no' reply — the old 'config get appendonly | tail -n
1' masked a failed docker exec (auth, transport) as empty output and
fell through to the destructive RDB replacement; only a genuine
substring 'yes' refused. The backup script's gate is strict for the same
reason (set -eu aborts on the failed exec).

A41: the redis restore snapshots the original dump AFTER the stop (a
graceful shutdown writes a final RDB the old copy-before-stop could
miss), the copy is mandatory when a dump exists, and a failed final
docker start now invokes the same restore_original compensation as a
failed install instead of exiting with redis down.

A44: backup ids carry a random 16-hex suffix — two backups of one app in
the same second used to target the same S3 key and silently replace each
other. ValidateDate accepts both the legacy timestamp form and the new
ids (with a real-date check); LatestBackupDate ordering still works since
the timestamp remains the prefix.

A46: cron fields must be unsigned decimals ('+1' and signed steps are
rejected), and SetSchedule itself validates the schedule plus rejects
line breaks/NUL in command and marker — a direct caller can no longer
bypass validation or split one job into unintended crontab lines.
…content-authoritative replay protection, race-free bounded prefix writers, cancellation-aware fleet slots

A51: PrefixWriter serializes its buffer (one writer is routinely handed
to BOTH stdout and stderr of the same command — docker.ScanImage does —
so concurrent stream copies raced before the shared sink lock), caps the
partial-line buffer at 64 KiB (newline-free output used to buffer
without bound), and surfaces write/flush failures into the deploy result
instead of discarding them. parallelDeploy acquires its concurrency slot
with a ctx-aware select and re-checks cancellation after (possibly)
waiting, so a cancelled fleet operation no longer queues for slots or
launches callbacks the caller already abandoned.

A36: dedup persistence snapshots and writes under one mutex, publishes
atomically (temp + rename), and reports failures to the log — concurrent
requests used to race os.WriteFile on the same path, an older snapshot
could overwrite a newer one, and every error was ignored. The delivery-ID
header no longer suppresses anything: it is log metadata only, because
every legitimate provider retry replays the same signed body (caught by
content dedup), while a REUSED delivery ID carrying different
authenticated content is a distinct event the old ID-only check wrongly
swallowed.
@im-tyler
im-tyler merged commit c30e4b3 into main Sep 19, 2026
1 check passed
@im-tyler
im-tyler deleted the audit/round3 branch September 19, 2026 09:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant