navidrome's install died on 2026-08-01 and left no explanation: the log
had only "Started container for navidrome (exit 1, up_app.sh:130)". The
compose output was captured into `result` and never read, and stderr was
not captured at all — so the one thing that says WHY (image pull EOF,
port clash, missing external network) was thrown away at the moment it
mattered.
Capture stderr and print the tail of the output on failure, before
checkSuccess (which can exit). Both the rootless and rooted call sites.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An image-pull failure (e.g. the MTU-EOF black hole) left apps marked
installed+active with no container: the compose `up -d` failure was invisible
because (a) checkSuccess read $? after `_rc=$?`, always printing "✓ Started",
and (b) the exit code is dropped across dockerComposeUpdateAndStartApp →
dockerComposeUpdate → dockerComposeRestartAfterUpdate, and installApp never
checked it — so post-install integrations ran and set status=1 regardless.
- up_app.sh: restore $? to the compose exit before checkSuccess (both rootless
and rooted) so a failed `up -d` is reported as an error + logged, not "✓".
- app_install.sh: after `up`, reality-gate on the app's compose project having
at least one container (ps -a, so a slow-to-start container still counts).
If none exists, print a clear failure, skip _appPostStartIntegrations (which
is what records the app + sets status=1), and return non-zero.
Verified: the gate query passes for a running app (libreportal) and refuses
apps with no container (navidrome/ipinfo after their pulls EOF'd).
Signed-off-by: librelad <librelad@digitalangels.vip>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A 4-lens adversarial security review of the Phase 2 applier raised 19 issues
and confirmed 17 after per-finding verification. All are trust-boundary (they
require the signing key), but several break the explicit "no code-exec, always
reversible, nothing-silent" contract, so all 17 are fixed:
Trust path — fail CLOSED, never misreport:
- lpFetchIndex now surfaces the real signature state (LP_INDEX_SIGSTATE);
artifactApply REFUSES to mutate unless the index is actually verified, and
_artifactFetchPayload refuses an unsigned payload. The read path still
tolerates dev/unsigned but now says "UNSIGNED" instead of "Signed + verified".
- valid_until and index_serial are now MANDATORY + numeric in lpFetchIndex
(missing = refuse) — closes the anti-withholding / anti-rollback fail-opens.
Injection / code-exec (defense in depth even for a signed payload):
- runFileWrite rootless branch no longer builds a `bash -c` shell string with the
destination interpolated — it uses the argv form (like runFileOp), so a path
with a quote can't inject a command as the install user. (shared-helper fix)
- op paths must match a safe-filename charset (no quotes/$/backtick/;/newline);
set-config-key values and set-compose-image refs are charset-guarded too.
- content_b64 is validated as real base64 at precheck.
Reversibility / honest failure:
- dockerComposeUp now returns the real compose exit status (it always returned 0,
so the updater's rollback gate AND the apply's start-failure detection were
fail-open). (shared-helper fix)
- set-config-key undo captures the WHOLE config file (lossless) instead of a
lossy re-parsed scalar; edit-only (rejects an absent key).
- _artifactReplayUndoFile returns non-zero if any inverse op fails; auto-rollback
and revert now record "rollback-incomplete"/"revert-incomplete" + isError
instead of falsely claiming success, and revert keeps the record for retry.
- applied-record write failure is checked — apply rolls back rather than leave an
un-revertable change. System-scope regen failure is no longer swallowed.
- Writes are path-aware (configs/ -> runInstallWrite, container tree ->
runFileWrite) so system-scope hotfixes write/restore correctly.
- Checked lazy-sourcing surfaces a clear error instead of a bare exit 127.
Unit-tested 35/35 (adds: command-sub value rejection, bad image-ref, invalid
base64, quote/metachar path-injection rejection, replay-failure reporting).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
'local result=$(cmd)' resets $? to 0 (the local builtin's own exit), so the
following checkSuccess always saw success regardless of cmd's real exit — the
mechanism that masked the de-sudo write failures. Split declaration from
assignment ('local result; result=$(cmd)') across all 235 active-code sites
(84 files) so the command's exit reaches checkSuccess. No behaviour change
beyond $? now being accurate (no set -e in runtime code; multi-line
assignments transform safely).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Container-plane docker now routes through the mode-aware helpers instead of
sudo: simple calls (exec/ps/run/build/images/inspect/port/logs across ~15
app/check scripts) -> runFileOp docker (rootless socket as the install user;
rooted via the docker group). The cd && docker compose paths drop the sudo on
the rooted branch (the rootless branch already used dockerCommandRunInstallUser
-- byte-identical now, manager-ready later); gluetun, which had no rootless
branch, now uses dockerCommandRun so force-recreate works in both modes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
A free, open, self-hosted app platform (GNU AGPLv3): one-click app deploys,
Traefik reverse proxy with automatic SSL, rootless Docker support, gluetun
VPN routing, and a web dashboard to manage it all.
Free & open forever to self-host; optional paid hosted services fund it.
See PROMISE.md.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>