v0.3.3: keep the restic repo password out of argv and shell history

Security
--------
create_task.py shelled out to `midclt call cloud_backup.create '<json>'`, and that
JSON carries the restic repository password -- so it sat in the subprocess's argv,
which is world-readable via ps, for the duration of the call. That password is the
encryption key for the entire cloud backup repository.

It now talks to the middleware through truenas_api_client, the library that backs
midclt itself, so the password never leaves this process's memory. Verified on a
live box: list-tasks and list-credentials work through the new transport.

--password is also no longer required, because passing a secret as a CLI argument
writes it to shell history permanently. --password-stdin reads it from stdin, and
with neither flag the tool prompts via getpass. --password still works but warns.

Fixed
-----
uninstall.sh could leave every patch installed. It reverted by unmounting the
overlay -- but apply.sh only mounts one when the target directory is read-only. On
a writable /usr it patches the real files in place, and uninstall would remove the
boot hook, report success, and leave the patch applied. It now strips the appended
blocks from the middleware files explicitly. This also covers the case where the
overlay unmount fails.

create_task.py's __version__ had been stuck at 0.2.0 through three releases. The
version-drift check added in v0.3.1 only looked at VERSION= in shell scripts, so it
missed the one file that actually shows a version to users (--version). The check
now covers __version__ too -- and caught this immediately.

118 tests, ruff and shellcheck clean.
This commit is contained in:
flan
2026-07-13 15:43:24 +00:00
parent 8aa9038226
commit 60b3ac4557
8 changed files with 277 additions and 31 deletions
+58 -1
View File
@@ -3,7 +3,7 @@
set -euo pipefail
VERSION="0.3.2"
VERSION="0.3.3"
PATCH_DIR="$(cd "$(dirname "$0")" && pwd)"
_HOOK_COMMENT='TrueCloud provider patch (S3/B2)'
@@ -91,6 +91,63 @@ if [ "$_ov_found" -eq 0 ]; then
fi
echo ""
# ── Revert file-level patches ─────────────────────────────────────────────────
# Unmounting the overlay is what normally reverts everything — the lower layer is
# the untouched /usr. But apply.sh only mounts an overlay when the directory is
# read-only; on a writable /usr it patches the real files in place. Uninstall
# would then remove the boot hook and report success while leaving every patch
# applied. Strip our appended blocks explicitly.
echo "Reverting any file-level patches ..."
python3 - <<'PYEOF'
import os
try:
import middlewared
except ImportError:
print(" middlewared not importable — nothing to revert.")
raise SystemExit(0)
mw = os.path.dirname(os.path.abspath(middlewared.__file__))
targets = [
os.path.join(mw, "rclone", "remote", "b2.py"),
os.path.join(mw, "plugins", "cloud_backup", "restic.py"),
os.path.join(mw, "plugins", "cloud_backup", "sync.py"),
os.path.join(mw, "plugins", "cloud", "crud.py"),
os.path.join(mw, "plugins", "cloud", "snapshot.py"),
]
reverted = []
for path in targets:
try:
with open(path, encoding="utf-8") as fh:
content = fh.read()
except OSError:
continue
idx = content.find("\n# TRUECLOUD_PATCH")
if idx == -1:
continue
try:
with open(path, "w", encoding="utf-8") as fh:
fh.write(content[:idx].rstrip("\n") + "\n")
reverted.append(os.path.basename(path))
except OSError as e:
print(f" WARNING: could not revert {path}: {e}")
nested = os.path.join(mw, "plugins", "cloud", "_truecloud_nested.py")
try:
os.unlink(nested)
reverted.append("_truecloud_nested.py")
except OSError:
pass
if reverted:
print(" Reverted: " + ", ".join(reverted))
else:
print(" Nothing to revert (overlay already removed, or never patched).")
PYEOF
echo ""
# ── Unmount nested-snapshot staging trees ─────────────────────────────────────
# These bind mounts pin their ZFS snapshots, so they must go before anything
# tries to destroy those snapshots. Deepest first.