The scoped sudoers grants the manager (root) and (dockerinstall) but NOT
(itself), so the many 'sudo -u $sudo_user_name <cmd>' calls (crontab,
git/update, reinstall, swapfile, …) failed with 'a password is required'
once per CLI command. runAsManager runs the command plainly when already
the manager (the runtime case) and only sudo -u's when root (install
time), so it's correct in both contexts and needs no sudoers self-grant.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
scan_files used 'sudo find' to enumerate config files to source. Under the
scoped sudoers that's denied, so NO configs got sourced -> CFG_DOCKER_INSTALL_TYPE
ended up empty -> runFileOp/runFileWrite fell back to the manager branch and
every container-path write failed. Root cause of the 'sudo: a password is
required' + 'tee: Permission denied' storm when running under the scoped grant.
- configs/ scan (manager-owned): plain find
- app_configs scan (/docker/containers, docker-install-owned, not list-readable
by the manager): runFileOp find (enumerate as that user; manager still sources
each .config, which is o+r). 'containers' install templates stay plain find.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Move the last runtime-critical root file-primitive subsystems behind
root-owned helpers so the type switcher + task service work under a scoped
sudoers:
- scripts/system/libreportal-socket: {rootless|rooted} {on|off} chmod of
the docker sockets (paths computed from config, not caller-supplied;
exit 3 = absent so the *_found flags come from its exit code)
- scripts/system/libreportal-svc: GENERATES + installs the systemd unit
from config (mode/uid/baked manager) — never accepts unit content from
the caller (arbitrary unit = root). Idempotent install/enable/restart.
- ownership helper: add db-own + app-file <app> <relpath> actions
- run_privileged: runSocket / runSvc
- set_socket_permissions -> runSocket; webui_install_systemd -> runSvc
(+ crontab cleanup runs as the manager directly, no sudo -u self)
- before_start: db chown -> runOwnership db-own; traefik cert/yml ->
runOwnership app-file (retires updateFileOwnership/changeRootOwnedFile)
- init.sh installs all five helpers
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Two more runtime root file-primitive subsystems moved behind self-
validating root-owned helpers so the scoped sudoers needn't grant blanket
sudo sed/tee/cp on /etc (which is root-equivalent — sudo arg wildcards
match across '/', so even path-scoped entries are bypassable):
- scripts/system/libreportal-dns: {clear|add <ip>} — edits /etc/resolv.conf
only, validates the IP argument
- scripts/system/libreportal-ssh-access: authorized_keys + sshd
PasswordAuthentication management, with the lockout guards moved INTO the
helper (the trust boundary) so a compromised manager can't bypass them
- run_privileged: _runRootHelper dispatcher + runResolv / runSshAccess
(runOwnership now uses it too)
- init.sh: initRootHelpers installs all three helpers root:root 0755 with
the manager name baked in
- setup_dns -> runResolv (+ ping de-sudo'd, works unprivileged); host_access
+ webui_ssh_access -> runSshAccess
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Under Model A the runtime runs as the manager, so establishing the
/docker ownership model needs root. Granting the manager a blanket
'sudo chown'/'sudo chmod' in the scoped sudoers would be root-equivalent
(chown /etc/sudoers, ...). Introduce a self-contained, root-owned helper
that performs only a FIXED set of reconciles on FIXED LibrePortal paths,
with owners derived from config + a baked manager name (never the caller)
and a strictly-validated app-name argument.
- scripts/system/libreportal-ownership: the helper (actions: reconcile,
traversal, containers-top, app-perms, webui, taskdir, app-data-nobody)
- run_privileged: runOwnership wrapper (sudo the installed helper; run the
bundled copy directly when already root mid-install)
- init.sh: installOwnershipHelper bakes the manager name and installs it
root:root 0755 to /usr/local/sbin (manager can't modify it)
- libreportal_folders/app_folder/app_update_specifics/task processor:
delegate the ownership chowns to runOwnership instead of runSystem chown
This removes chown/chmod-on-/docker from the runtime sudo surface, a
prerequisite for a non-root-equivalent scoped sudoers.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
The CLI wrapper already runs as the manager (libreportal) but then did
'sudo ./start.sh', so the whole runtime executed as root — the reason
NOPASSWD:ALL was load-bearing. Drop that sudo so start.sh runs as the
manager; also drop the now-redundant sudo from the wrapper's own
manager-owned ops (config sed, /docker/configs + /docker/install
mkdir/cp/chown/rm, 'sudo -u libreportal' git clone, chmod). Only the
'cp -f init.sh /root/' copies stay root.
Running as the manager surfaced data-plane writes that only worked under
root; fixed to be owner-correct:
- webui_system_metrics: .metrics_{cpu,net}_prev state via runFileWrite
- atomicWriteWebUI: path-aware temp+chmod+mv (atomic same-dir rename as
the path owner) instead of bare >/mv
- webui_app_config last_update trigger via runFileWrite
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
- docker_run: in rooted mode run docker AS the manager via the docker
group (no sudo); the type=='sudo' branch was unreachable dead code
- 8 db helpers: fix 'command -v sudo sqlite3' guard to 'command -v
sqlite3' (bodies already query via runInstallOp)
- restic/kopia single-file dump: write target_file via runBackupOp tee
(as the backup user, matching the snapshot-restore path) instead of
root tee
- adguard auth: root-owned scratch via runSystem mktemp
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
The borg/restic/kopia engines all dropped to the dedicated backup user
via scattered 'sudo -E -u $docker_install_user'. Centralize that into a
single runBackupOp helper so the backup subsystem has one audit point and
the scoped sudoers needs only the (dockerinstall) drop rule.
Also:
- owncloud config heredoc tees -> runSystem (container-UID file)
- webui_display_logins: fix the broken 'command -v sudo sqlite3' guard
to 'command -v sqlite3' (body already runs sqlite3 via runInstallOp)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
tagsManagerUpdateUniversalTag did a bare 'sed -i "$file_path"' — works only
because start.sh runs as root today; under Model-A-as-manager the manager
can't create sed's temp file in the dockerinstall-owned containers dir
(permission denied). Make the in-place edit run AS the file's owner: classify
by path (containers/<app> -> runFileOp, manager configs/templates ->
runInstallOp), like createTouch. The awk read stays unescalated (config/compose
are world-readable). Unblocks running the whole app as the manager for tag ops.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Wireguard standalone touches /etc/wireguard + sysctl exclusively (genuine
root) -> runSystem for all its mkdir/chmod/sed/rm/grep/tee/qrencode. Traefik
dynamic configs live under containers/traefik (docker-install-owned) ->
runFileOp/runFileWrite (whitelist.yml, protectionauth.yml, the router-rewrite
awk|tee|mv in port_subdomains). sudo -u drops left.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
- copy_build_context: rsync/cp/rm -> runFileOp (writes the deployed tree AS the
container owner with --no-owner); drop the now-redundant runSystem chown.
- setup_lock: .setup_complete is in the docker-install-owned frontend/data ->
runFileOp touch/chmod/rm (drop the chown).
- tags_processor_docker_installation 'user:' enable + update_compose_yml
jail.local -> runFileOp (deployed compose/config under containers).
- crontab_clear: clear the manager's own crontab via runInstallOp.
- reinstall: cp init.sh to /root -> runSystem (genuine root path).
- create_successful_run_file: drop the pointless sudo echo -> runInstallWrite to
/docker/run.txt.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
scanConfigsForRandomPassword iterates $configs_dir (manager-owned), so the
placeholder grep/sed/awk on the config file -> runInstallOp. The bcrypt export
log ($containers_dir/bcrypt.txt) is docker-install-owned, so its touch/chmod/
sed/grep/append -> runFileOp/runFileWrite (NOT runInstallOp). Covers all
password_replace*/password_user_replace/password_update_all and bcrypt/*.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
The old copy/move helpers ran 'sudo cp/mv X Y; sudo chown $user_name Y' (root +
arbitrary chown). Rework them to write AS the destination's owner — no root, no
chown — classifying by dest path like createTouch: /docker/containers/<app> ->
runFileOp (docker install user), manager-owned control plane -> runInstallOp.
The $user_name arg is now advisory (the path decides). Covers copyFile/copyFiles/
copyFolder/copyFolders/moveFile; copyResource is always containers -> runFileOp;
createFolders' non-container branch -> runInstallOp; updateFileOwnership (an
arbitrary user1:user2 chown) -> runSystem. Confirmed by callers (containers vs
$docker_dir/backup_install_dir/configs dests). Removes a class of root data ops
+ arbitrary-chown from the runtime.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
app_generate operates on the manager-owned install template -> runInstallOp
(cp/mv/sed); drop sudo on the interactive editor. localDnsApplyPihole edits
containers/pihole/.../custom.list (docker-install-owned) -> read via runFileOp,
build in a manager /tmp scratch, write back via runFileWrite.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
htpasswd -bnBC just computes a bcrypt hash to stdout (no file/root access), so
the sudo was unnecessary — drop it in the adguard/focalboard/invidious auth
helpers and password_hash. (App-config file edits owned by container UIDs —
owncloud config.php/adguard yaml — are deferred as category-3 cross-owner work
for the root-owned ownership helper.)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
The git-update backup helpers operate on the manager-owned $backup_install_dir:
use_git_backup unzip + config_git_check find -> runInstallOp; install_git_backup
standalone find -> runInstallOp (drop the nested -exec sudo rm), and its
cd && find | xargs rm pipeline drops its sudos (manager owns the dir). The
many 'sudo -u $sudo_user_name git/rm/zip' calls stay (already least-privilege).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
The backup engine already drops to the backup user (sudo -E -u
$docker_install_user) and backupLocationOwner == $docker_install_user, which is
exactly what runFileOp/runFileWrite resolve to in both modes. So convert the
raw-sudo data ops (mkdir/chmod/rm/find/cat/grep/mv/chown/tee on backup repos,
location configs, keys, manifests) to runFileOp/runFileWrite — creating files
as the owner directly, no root chown. backup_verify creates its scratch as the
backup user (runFileOp mktemp) instead of chown-after. Binary installs
(kopia tar/install, borg dnf) -> runSystem. The 44 sudo -u engine drops stay
(already least-privilege; the scoped sudoers will grant them).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Model A prototype (run start.sh AS the manager, escalate only via helpers):
- check_root.sh: accept the manager user, not root-only (init.sh keeps its own
install-time root check).
- init.sh: guard the top-level root-check + installer entrypoint with
BASH_SOURCE!=$0 so it runs ONLY when init.sh is executed directly; when
start.sh sources it as the manager the entrypoint (and its root check) no
longer fires.
Also: convert bare daemon-touching 'docker' calls (no helper -> hit the
nonexistent /var/run socket in rootless) to runFileOp docker across
app_status, app_health_*, network_prune, ip_is_available, check_docker_network,
backup_db (db dumps) and crontab_check_processor. cd&&compose rooted-branches
and 'docker compose --version' checks left as-is (rooted-only / no daemon).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Add webui_system_metrics.sh, run each minute from webuiSystemUpdate:
- whole-server snapshot (metrics.json): CPU% + load, memory + swap,
per-mount disk + inodes, network rx/tx rate, docker summary
- capped ring buffer (metrics_history.json, 24h default) for trend charts
- per-app docker stats grouped by compose project (metrics_apps.json)
plus a short per-app history (metrics_apps_history.json) for sparklines
CPU% and network rate use stateful deltas stashed beside the JSON; all
host metrics read from /proc and docker via runFileOp, so it works rootless.
Co-Authored-By: Claude Opus 4.7 <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>
The apps SQLite DB ($docker_dir/$db_file) is owned by the manager user, so
read/write it AS the manager via runInstallOp instead of sudo (root). 48 call
sites across 28 scripts. In rooted this drops root->manager (correct owner);
in rootless it's the manager too (using runFileOp/dockerinstall here was the
'unable to open database' bug). The broken 'command -v sudo sqlite3' check
lines are left untouched (separate pre-existing issue).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Foundation for a scoped sudoers: route every genuine system-admin command
(systemctl/ufw/ufw-docker/nft/apt/apt-get/pacman/sysctl/useradd/usermod/
service/wg/wg-quick/cscli/loginctl) through runSystem instead of raw sudo
across 28 active scripts. runSystem is 'sudo "$@"' so this is byte-identical
in every mode (safe on live installs) — it just collects all real-root use at
one chokepoint that will define the eventual /etc/sudoers.d allowlist.
Also: revert a crowdsec advice message the sweep wrongly rewrote (the admin
types sudo, not runSystem), and give crontab_check_processor.sh the same
startup bootstrap as the task processor — it runs standalone via cron and
already used runFileOp/runFileWrite (undefined there), so it was silently
broken; now it sources the helpers + docker-type config.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
install_user_manager appended a 'Match User' block to sshd_config with no
marker guard, unlike the rootless .bashrc block beside it. The enclosing
'if ! userExists' gate hides it today, but a user delete+recreate would append
a second block. Guard on the '### LibrePortal Manager User Start' marker so the
append is idempotent.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
installLibrePortalWebUITaskService only wrote the unit if it didn't already
exist, so env/User/mode changes never reached an existing install and a
docker-type switch couldn't update the service. Make it converge: compute the
desired unit for the current mode and only rewrite + daemon-reload + restart
when it actually differs (otherwise just ensure enabled+running, no restart, so
routine re-runs don't bounce the processor and kill in-flight tasks). The
docker-type switcher now calls this idempotent setup (replacing the one-shot
restart helper), so a swap updates the env AND restarts in one step.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
The rootless task-processor service env used id -u $sudo_user_name (the
manager, e.g. 1001) for DOCKER_HOST/XDG_RUNTIME_DIR, but the rootless daemon
runs as the docker install user, so its socket lives at
/run/user/<install-user-uid>/docker.sock (e.g. 1002). The manager-uid path
doesn't exist. Use id -u $CFG_DOCKER_INSTALL_USER so the env matches the
actual rootless socket (same values dockerCommandRunInstallUser uses).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
The task processor reads CFG_DOCKER_INSTALL_TYPE once at startup to decide how
runFileOp writes into the task dir (rootless -> as the docker install user,
rooted -> as the manager). After a rooted<->rootless swap a running instance
keeps the old mode and writes task files wrong. Add
restartLibrePortalWebUITaskService and call it at the end of both switch
branches so the processor re-sources the new mode. The switch is a CLI
one-shot, not a processor task, so the restart won't interrupt it.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
The dir-ownership chown used runFileOp (the unprivileged dir owner), which
can't reclaim files a prior run left root/manager-owned — leaving a root-owned
task_processor.log the daemon then couldn't append to. Use runSystem (root) so
ownership is actually established.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
systemd launches the processor standalone, so it never sourced the LibrePortal
function library — runFileOp/runFileWrite were 'command not found' at runtime,
so it couldn't write its log, create its lock (flock died on a bad fd), or
update task status. Every task stayed queued and looped forever, and the setup
'finalize' never ran.
Source the privilege helpers (run_privileged.sh, docker_run_install.sh,
check_install_type.sh) + read the docker-type config at startup so runFileOp
knows rooted vs rootless. Also create the lock and per-task log via runFileOp
(world-writable) so the manager-user processor can open/append them in the
docker-install-owned task dir.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
updateTaskFields wrote its temp with a plain 'jq … > "$tmp"' shell redirect,
which runs as the processor's own user (the manager). But TASK_DIR is owned by
the docker install user and the manager can't create files in it, so the
redirect failed and the status write silently no-op'd — every task stayed
'queued', got reprocessed in an endless loop, and follow-on tasks (e.g. the
setup 'finalize' after 'config') never ran. The fix mirrors writeAtomic:
capture jq's output, write the temp through runFileWrite (the privileged
helper), then chmod + atomic mv.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
dockerCopyBuildContext rsync'd the install template into the container dir
with -a, which preserves owner/group — so the deployed WebUI tree (frontend/
included) inherited the repo clone's owner (the human user, uid ~1000) on
every install. The trailing chown used the $docker_install_user global, which
is stale/empty in this context, so it silently no-op'd and uid 1000 survived
(visible as frontend/ owned by 1000 with the template's mtime).
Add --no-owner --no-group so the copy doesn't carry source ownership, and
chown via the config-authoritative dockerContainerOwner (rooted -> manager,
rootless -> docker install user) through runSystem. The deployed tree now
lands owned by the mode's container owner.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
The remaining WebUI generators built JSON into a temp file inside the
output dir then placed it with mv/sudo mv + a createTouch that can't re-own,
so in rootless they produced root/libreportal-owned data and 'touch:
Permission denied' spam. Two problems: the temp lived in the (now
dockerinstall-owned) output dir, which the cron updater — running as
libreportal — can't write; and the final file landed wrong-owned.
Move each temp to mktemp (/tmp, writable by whoever runs the updater) and
place the result via runFileWrite (writes as the container owner:
dockerinstall in rootless, manager in rooted), dropping the redundant
createTouch; convert the dir mkdirs to runFileOp. Covers apps
(services/config/tools/app_status/gluetun/config_patch), categories
(app/config-categories/field-mappings), config (configs.json) and system
(info/memory/disk/update). The logs file is handled by the now mode-aware
createFolders + createTouch.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
The backup + ssh generators created their frontend/data dirs via plain/sudo
mkdir and wrote files via sudo tee/mv (root-owned), then called createTouch
(dockerinstall) which can't re-own a root file — so every write hit
'touch: Permission denied' in rootless and left root-owned data the
dockerinstall container/generators can't rewrite. Convert dir creation to
runFileOp mkdir and file writes to runFileWrite (both run as the container
owner: dockerinstall in rootless, manager in rooted), dropping the
temp/mv/createTouch dance. Also make the createFolders chokepoint mode-aware
(containers/ paths created via runFileOp) so it mirrors createTouch.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
dockerCommandRunInstallUser sudo's to the unprivileged docker install user but
inherited the caller's cwd. At install time the caller is root in /root, which
that user can't enter, so cwd-sensitive tools failed — e.g. 'find: Failed to
change directory: /root' / 'Failed to restore initial working directory'
during the app scan (the scan still worked via the absolute start path, but
the errors are noise and could bite other commands). Add env --chdir to the
install user's HOME for both the argv and shell exec paths so every runFileOp
runs from a directory the user can access.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
The firewall rebuild chose ufw-docker vs ufw from $EUID -eq 0 (am I root?)
rather than the docker mode. During a rootless install everything runs as
root, so it wrongly picked ufw-docker — which manages the rooted daemon's
DOCKER-USER chain that rootless never creates — and failed with 'Docker
instance libreportal doesn't exist'. (It was also inconsistent at runtime: the
non-root cron refresh always fell through to plain ufw.) Select by
CFG_DOCKER_INSTALL_TYPE so rootless always uses plain ufw (ports are published
on the host) and rooted always uses ufw-docker.
Also: ufw-docker needs the container name, not the app name — pass
service_name (e.g. libreportal-service) with an app_name fallback; route the
traefik-detect docker ps through runFileOp (was raw docker -> /var/run in
rootless); and move the ufw/ufw-docker sudo calls to runSystem.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
The install/start paths and the switch reconcile managed /docker ownership
separately, so a fresh install produced different ownership than a post-switch
state — the root cause of the rootless 'touch: Permission denied' storm.
Consolidate onto the reconcile model:
- dockerContainerOwner(): single definition of the mode's container owner
(rooted -> manager, rootless -> config-authoritative docker install user).
- reconcileContainersTopOwnership(): owns + makes traversable the structural
containers/ top dir; now also run by the switch reconcile (previously only
the install pass set it, so a rootless->rooted switch left it stale).
- reconcileWebuiDirOwnership(): now uses dockerContainerOwner.
- reconcileDockerOwnership(): calls both helpers.
- fixFolderPermissions(): slimmed to the +x traversal bits; its ad-hoc
containers/ chown is now the shared helper.
- fixPermissionsBeforeStart(): drop changeRootOwnedFilesAndFolders (a
pre-de-sudo band-aid that only fixed root-owned files and ran contrary to
the don't-touch-third-party-data rule); reconcile the WebUI dir via the
shared helper instead. Delete the now-unused root_files_folders.sh and
regenerate the source arrays.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Three docker calls ran the binary directly (two plain, one sudo), so in
rootless they hit /var/run/docker.sock (the rooted socket, absent) and
printed 'Cannot connect to the Docker daemon' — the WebUI-image requirement
check, the system-disk WebUI generator (docker system df), and the
app-install fallback (docker ps). Route all three through runFileOp, which in
rootless runs as the docker install user with DOCKER_HOST set and is
argv-safe for --format, and in rooted runs as the manager via the docker
group.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
A fresh rootless install left /docker/containers/libreportal/frontend owned
by the manager (webui_install_image chowned -R to $sudo_user_name) while the
WebUI container and the host-side runFileOp generators run as dockerinstall.
So every generator touch under frontend/data and frontend/logs failed with
'Permission denied' (~27 in the install log). reconcileDockerOwnership chowns
the WebUI dir to the mode's container owner, but only runs on a mode switch,
not on a fresh install.
Extract that WebUI-dir chown into reconcileWebuiDirOwnership (rooted ->
manager, rootless -> the config-authoritative docker install user; runs as
root so it can chown either way) and call it from both reconcileDockerOwnership
and the fresh-install WebUI setup. A fresh install now lands the same
ownership a switch does, so the dockerinstall generators can write.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Switching rooted<->rootless re-maps every container's on-disk UIDs (rootless
offsets them by the subuid base), so a stateful app's data no longer lines up
in the new mode and a chown can't carry it. The portable carry is backup
(old mode) -> switch -> restore (new mode): restoreAppStart wipes and re-lays
each tree and re-owns it to the new mode's install user, which is exactly the
remap needed.
Wire that into dockerSwitcherSwap:
- switchMigrateBackupApps <old_mode>: before the switch, back up every
installed app except libreportal (reconcile already carries the control
plane). CFG_DOCKER_INSTALL_TYPE is already the target mode by the time the
switcher runs, so force it (and the resolved install user) back to the old
mode for the backups, else backupAppStart would talk to the not-yet-running
new daemon. Any backup failure aborts the switch before the daemon is
touched (nothing changed). No backup location enabled -> skip and keep the
manual warning.
- switchMigrateRestoreApps: after the new daemon is up, restore each captured
app best-effort, re-resolving the install user first so data is owned
correctly; failures are reported per app rather than blocking.
Subject to the existing backup-completeness limitation (restic-as-libreportal
can't read files owned by other UIDs unless the app declares container-side
file capture) — same caveat as the manual procedure this automates.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
The switch-to-rootless branch passed the literal 'root' to mode-aware
helpers whose vocabulary is 'rooted'/'rootless' (matching
CFG_DOCKER_INSTALL_TYPE). Two were real no-ops: dockerComposeDownAllApps
root never matched dockerComposeDown's 'rooted' check (old rooted apps were
never composed-down before the switch), and dockerServiceStop root never
matched dockerServiceStop's 'rooted' check (the old rooted docker service
was never stopped/disabled). dockerServiceStart root was harmless only
because that function ignores its arg and reads CFG. Both no-ops were
silent until dockerComposeDown started reporting unknown modes. Align all
three to 'rooted'.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
The single long isNotice was hard to read in both source and terminal
output. Break it into three lines (re-map warning / backup-restore guidance
/ app-data note).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
The control-plane/app-install-user detail was noise on the success line;
keep it concise as 'Reconciled ownership for <mode>'.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
dockerComposeDown printed the 'Docker Compose down <app>' header then could
fall through silently: when the effective install type (passed type arg or
CFG_DOCKER_INSTALL_TYPE) was empty/unrecognised no branch ran, and on a
non-Ubuntu/Debian OS the whole block was skipped. Collapse the duplicated
type=='' vs type!='' branches into one mode fallback and add notices for the
unknown-mode and unsupported-OS cases so the header always has a result line.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
The rooted branch executed the command string as bare $command, which
word-splits without shell interpretation: pipes, redirects, && and quoted
Go templates were passed as literal argv to a single process. Nearly every
caller relies on shell syntax (docker ps | xargs -r ..., cd && docker
compose, --format='{{...}}', > /dev/null), so rooted mode silently
mishandled them — most visibly dockerStartAllApps after its pipe rewrite,
which failed with 'unknown shorthand flag: r'. Run via bash -c like the
rootless path so both modes share identical shell semantics. No caller uses
the sudo type arg.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
dockerStartAllApps expanded $(docker ps -a -q) in the outer control-plane
shell, which has no DOCKER_HOST and so hit the nonexistent rooted socket at
/var/run/docker.sock. In rootless mode that connection fails, the
substitution returns empty, and 'docker restart' is then called with no
arguments. Push the whole pipeline into dockerCommandRun (matching
dockerRestartApp) and guard with xargs -r so it runs against the rootless
socket and no-ops cleanly when there are no containers.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
The 'Update the .bashrc file' step printed its header but, when the rootless
block was already present, the if-guard skipped the whole body with no output
— looked like nothing happened. Add an else that notes it's already configured.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
ROOT CAUSE of the WebUI-dir misownership on rooted->rootless:
check_install_type.sh sets the lowercase $docker_install_user to the MANAGER
user in rooted mode (it's a mode-dependent 'container owner' var). reconcile
trusted it, so mid-switch it held the stale rooted value (=manager) and chowned
the rootless WebUI dir to libreportal -> WebUI Exited(137) -> dockerStartAllApps
retried forever (the 'switch hangs' symptom). Now read CFG_DOCKER_INSTALL_USER
straight from the live config file (authoritative, never polluted), falling back
to the CFG var then a hard default.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Previous fix still no-op'd the WebUI-dir chown: in the CLI/switch context the
path globals (containers_dir etc.) and the install-user vars can be unset,
making webui_dir a relative path the [[ -d ]] check skips, and the chown user
empty. Resolve everything with absolute-path fallbacks and read the install
user from the live config file when the vars are empty (never empty now), and
log what was reconciled (incl. a 'WebUI dir not found' notice) so a switch is
diagnosable.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Bug found via round-trip: after rooted->rootless the WebUI dir stayed
libreportal instead of dockerinstall, so the rootless WebUI Exited(137).
Cause: reconcile referenced $docker_install_user, which is unset in the
CLI/switch context (only $CFG_DOCKER_INSTALL_USER is, like the rootless
helper uses) -> chown to an empty user no-op'd. Use
${docker_install_user:-$CFG_DOCKER_INSTALL_USER} (and ${sudo_user_name:-libreportal})
so reconcile resolves the users reliably in any context.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>