Files
truenas-truecloud-patch/uninstall.sh
T
flan 60b3ac4557 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.
2026-07-13 15:43:24 +00:00

195 lines
6.7 KiB
Bash
Executable File

#!/bin/bash
# uninstall.sh — remove all traces of truecloud-patch from a TrueNAS box.
set -euo pipefail
VERSION="0.3.3"
PATCH_DIR="$(cd "$(dirname "$0")" && pwd)"
_HOOK_COMMENT='TrueCloud provider patch (S3/B2)'
echo "=== TrueNAS TrueCloud Provider Patch v${VERSION} — Uninstall ==="
echo ""
if [ "$(id -u)" -ne 0 ]; then
echo "ERROR: must be run as root." >&2
exit 1
fi
if ! command -v midclt &>/dev/null; then
echo "ERROR: midclt not found. Run this script on TrueNAS SCALE." >&2
exit 1
fi
# ── Remove PREINIT hook ───────────────────────────────────────────────────────
echo "Removing PREINIT boot hook ..."
IDS=$(midclt call initshutdownscript.query '[]' | \
python3 -c "
import sys, json
for s in json.load(sys.stdin):
if s.get('comment') == '$_HOOK_COMMENT':
print(s['id'])
" 2>/dev/null || true)
if [ -n "$IDS" ]; then
for id in $IDS; do
if midclt call initshutdownscript.delete "$id" > /dev/null; then
echo " Removed initshutdownscript id=$id"
else
echo " WARNING: could not delete id=$id (already gone?)"
fi
done
else
echo " No entry found (already removed or never installed)."
fi
echo ""
# ── Restore UI bundle ─────────────────────────────────────────────────────────
echo "Restoring UI bundle backup ..."
RESTORED=0
_restore_failed=0
while IFS= read -r backup; do
original="${backup%.pre-truecloud-patch}"
if mv "$backup" "$original"; then
echo " Restored: $original"
RESTORED=1
else
echo " WARNING: Could not restore $original — backup left at $backup"
_restore_failed=1
fi
# Keep these paths in sync with WEBUI_CANDIDATES in patch/patch_ui.py
done < <(find /usr/share/truenas /usr/share/truenas-ui /var/www/truenas \
-name "*.js.pre-truecloud-patch" 2>/dev/null)
if [ "$RESTORED" -eq 0 ]; then
echo " No backup files found."
echo " On an immutable OS the UI patch is volatile and already gone after reboot."
fi
echo ""
# ── Unmount overlays ──────────────────────────────────────────────────────────
echo "Unmounting truecloud overlays (if any) ..."
_ov_found=0
for _tag in mw ui; do
if mount | grep -qF "truecloud-${_tag} on "; then
_ov_mnt=$(mount | grep "truecloud-${_tag} on " | awk '{print $3}' | head -1)
if umount "$_ov_mnt" 2>/dev/null; then
echo " Unmounted: $_ov_mnt"
else
echo " WARNING: Could not unmount overlay on $_ov_mnt"
fi
_ov_found=1
fi
done
if [ "$_ov_found" -eq 0 ]; then
echo " None active."
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.
# Delegated to the patch module rather than reimplemented here: the depth
# ordering and lazy-umount fallback are fiddly, and a shell copy would be the
# untested one.
echo "Unmounting nested-snapshot staging trees (if any) ..."
if ! python3 "$PATCH_DIR/patch/truecloud_nested.py" cleanup; then
echo " WARNING: staging mounts remain. Unmount them manually; until you do,"
echo " the ZFS snapshots they pin cannot be destroyed."
fi
# The opt-in marker lives in the repo dir; remove it so a later re-install
# starts from the safe default (feature off).
if [ -f "$PATCH_DIR/nested_snapshots_enabled" ]; then
rm -f "$PATCH_DIR/nested_snapshots_enabled"
echo " Removed nested-snapshot opt-in marker."
fi
echo ""
if [ "$_restore_failed" -eq 1 ]; then
echo ""
echo "ERROR: One or more UI bundle backups could not be restored." >&2
echo " $PATCH_DIR has been left intact (recover.sh and patch files are safe)." >&2
echo " Restore the backup(s) manually, then re-run uninstall.sh." >&2
exit 1
fi
# Cancel a deferred boot restart if one is still queued — we restart ourselves.
systemctl stop truecloud-mw-restart.service 2>/dev/null || true
systemctl reset-failed truecloud-mw-restart.service 2>/dev/null || true
echo "Restarting middlewared ..."
if systemctl restart middlewared; then
echo ""
echo "Uninstall complete. Refresh your browser to see the restored UI."
else
echo ""
echo "WARNING: middlewared did not start cleanly after uninstall."
echo "Check the system log for details:"
echo " journalctl -u middlewared -n 50"
exit 1
fi