117 Commits

Author SHA1 Message Date
librelad
9af2465ffe feat(desudo): socket + systemd-svc helpers; route traefik/db chowns + svc
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>
2026-05-24 18:28:56 +01:00
librelad
d17e8814d0 feat(desudo): root-owned DNS + host-SSH-access helpers
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>
2026-05-24 18:21:46 +01:00
librelad
46622cd2f9 feat(desudo): root-owned ownership helper (no blanket sudo chown needed)
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>
2026-05-24 18:16:23 +01:00
librelad
78e7651ea0 feat(desudo): run start.sh AS the manager (Model A flip) + fix exposed writes
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>
2026-05-24 18:09:20 +01:00
librelad
21afae2eff refactor(desudo): drop runtime root from docker_run, sqlite guards, restores
- 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>
2026-05-24 18:03:36 +01:00
librelad
0b27ed1072 refactor(desudo): funnel backup-engine privilege drop through runBackupOp
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>
2026-05-24 18:01:51 +01:00
librelad
8b14f26125 refactor(desudo): route scattered runtime sudo through privilege helpers
Convert the remaining ad-hoc 'sudo' calls across the data plane to the
run_privileged helpers so every file op lands as the correct owner with
no blanket root:

- DB/configs (manager-owned): db_list_all_apps, delete_db_file,
  install_sqlite, cli_webui_commands -> runInstallOp
- containers (dockerinstall-owned): scan_container_socket, delete_data,
  webui_task_files, webui_app_log, webui_config_patch,
  application_missing_variables, uninstall_app -> runFileOp/runFileWrite
- genuine root: passwd, tailscale, ufw-docker, sysctl grep, systemd
  unit read, authorized_keys read, nobody chown -> runSystem
- interactive editors and 'id -u': drop sudo entirely (run as caller)
- owncloud/adguard container-UID config edits -> runSystem (funnel;
  docker-exec rework deferred)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-24 18:00:19 +01:00
librelad
eea5b41e68 fix(rootless): tagsManager in-place edit via runFileOp/runInstallOp
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>
2026-05-24 17:55:07 +01:00
librelad
4ee231ae9f refactor(de-sudo): wireguard -> runSystem, traefik -> runFileOp
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>
2026-05-24 17:37:14 +01:00
librelad
2c907b25c2 refactor(de-sudo): compose/setup/run misc off raw sudo
- 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>
2026-05-24 17:35:09 +01:00
librelad
5ceef2df6a refactor(de-sudo): config/password processors off raw sudo
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>
2026-05-24 17:30:25 +01:00
librelad
33107c4f27 refactor(de-sudo): rework generic file/folder helpers to path-aware ownership
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>
2026-05-24 17:24:44 +01:00
librelad
3466f112fa refactor(de-sudo): app_generate + local-dns off raw sudo
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>
2026-05-24 17:14:31 +01:00
librelad
07b3e7896d refactor(de-sudo): drop pointless sudo on htpasswd hash computation
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>
2026-05-24 17:06:46 +01:00
librelad
92c731952b refactor(de-sudo): update/git backup helpers off raw sudo
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>
2026-05-24 17:05:37 +01:00
librelad
92c0261ca4 refactor(de-sudo): config-plane + permission helpers off raw sudo
config_scan_variables + config_check_missing operate on the manager-owned
configs_dir -> runInstallOp (test/cat/cmp/cp/mkdir). Container-path chmods in
before_start (traefik) + config.sh -> runFileOp. Fix the 'sudo sudo chown'
double in root_file.sh -> runSystem chown (ownership establishment).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-24 17:03:19 +01:00
librelad
7acfdabbac refactor(de-sudo): backup subsystem data ops via runFileOp/runFileWrite
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>
2026-05-24 17:01:05 +01:00
librelad
a3afb2aeae feat(model-a): run app as manager; route bare docker calls through runFileOp
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>
2026-05-24 16:53:37 +01:00
librelad
bade6eaacb feat(webui): collect host + per-app metrics with history ring buffer
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>
2026-05-24 16:46:46 +01:00
librelad
3ecf213cab refactor(de-sudo): docker calls via runFileOp/dockerCommandRun, drop sudo
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>
2026-05-24 16:29:22 +01:00
librelad
c6dd2659be refactor(de-sudo): apps DB access via runInstallOp, not sudo
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>
2026-05-24 16:23:33 +01:00
librelad
014d8e5fcc refactor(de-sudo): funnel genuine system commands through runSystem
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>
2026-05-24 15:21:53 +01:00
librelad
ba385a8b97 fix(manager): marker-guard the manager-user sshd append
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>
2026-05-24 15:11:35 +01:00
librelad
e5f637bca6 refactor(service): make task processor service setup idempotent
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>
2026-05-24 15:01:31 +01:00
librelad
c9445b4e78 fix(rootless): task service DOCKER_HOST points at the install user's socket
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>
2026-05-24 14:52:41 +01:00
librelad
5f4f4eb96f feat(switcher): restart the task processor after a docker-type swap
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>
2026-05-24 14:48:17 +01:00
librelad
7314319090 fix(rootless): establish task-dir ownership with runSystem in setupTaskDir
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>
2026-05-24 14:44:02 +01:00
librelad
d3f073a107 fix(rootless): task processor must load the de-sudo helpers itself
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>
2026-05-24 14:42:16 +01:00
librelad
099751b72c fix(rootless): task processor status writes via runFileWrite, not bare redirect
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>
2026-05-24 14:36:22 +01:00
librelad
4c8bcf0580 fix(rootless): don't stamp the deployed WebUI tree with the repo-clone uid
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>
2026-05-24 14:13:15 +01:00
librelad
ed9697cdc0 fix(rootless): apps/categories/config/system generators write as container owner
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>
2026-05-24 14:07:46 +01:00
librelad
75dfb3849b fix(rootless): backup/ssh WebUI generators write as the container owner
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>
2026-05-24 14:04:06 +01:00
librelad
3a0bcaccb6 fix(rootless): run install-user commands from HOME, not the caller cwd
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>
2026-05-24 13:50:20 +01:00
librelad
85f8130a49 fix(firewall): pick backend by docker mode, use container name
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>
2026-05-24 13:47:40 +01:00
librelad
8e0d662a16 refactor(perms): one source of truth for container ownership
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>
2026-05-24 13:46:12 +01:00
librelad
ecd3db5632 fix(rootless): route requirement/disk docker checks through runFileOp
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>
2026-05-24 13:39:06 +01:00
librelad
d310249ce1 fix(rootless): own the WebUI dir as the container user on fresh install
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>
2026-05-24 13:37:42 +01:00
librelad
e7659926db feat(switcher): hands-off data migration across a docker mode switch
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>
2026-05-24 13:26:24 +01:00
librelad
a7542fe716 fix(switcher): pass 'rooted' not 'root' when switching to rootless
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>
2026-05-24 13:17:00 +01:00
librelad
bb3e560cb2 style(switcher): split the docker-mode-switch warning across notices
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>
2026-05-24 13:10:52 +01:00
librelad
85ff76b519 refactor(perms): trim ownership-reconcile success line to just the mode
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>
2026-05-24 13:09:33 +01:00
librelad
7b7e6e06fb fix(compose): always emit a status line when downing an app
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>
2026-05-24 13:09:21 +01:00
librelad
99460cb05e fix(rooted): run dockerCommandRun via bash -c so pipes/redirects work
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>
2026-05-24 13:05:24 +01:00
librelad
ee4f7cedbf fix(rootless): run docker ps inside the install-user shell when restarting all containers
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>
2026-05-24 13:02:44 +01:00
librelad
a361c5bb9e fix(rootless): show a message when .bashrc is already configured
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>
2026-05-24 12:59:41 +01:00
librelad
03afcfa4f1 fix(switcher): read rootless install user authoritatively from config, not the mode-polluted global
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>
2026-05-24 02:23:00 +01:00
librelad
474ba55764 fix(switcher): bulletproof reconcile var/path resolution + diagnostics
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>
2026-05-24 02:03:11 +01:00
librelad
eab9565c49 fix(switcher): reconcile uses CFG_DOCKER_INSTALL_USER (lowercase var empty in CLI ctx)
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>
2026-05-24 01:51:39 +01:00
librelad
10af56b9c4 refactor(desudo): rooted ops run as the manager user, not sudo->root
Maintainer confirmed the intended model: the manager user (libreportal, in the
docker group) owns /docker in BOTH modes and runs things directly; root:root was
always an accident of un-de-sudo'd sudo. Rework the helpers accordingly:

- add runAsManager (run as the manager: plain when already it at runtime, else
  sudo -u at install time) so files end up manager-owned, never root-owned.
- runFileOp/runFileWrite: rooted -> runAsManager (was sudo->root); rootless
  unchanged (docker install user owns containers/).
- runInstallOp/runInstallWrite: always runAsManager (control plane is manager-
  owned in both modes).
- runSystem unchanged (genuine root: apt/systemctl/ufw/sysctl).
All ~40 converted call sites inherit this via the helpers. reconcile's WebUI dir
now -> manager in rooted / docker install user in rootless.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-24 01:40:58 +01:00
librelad
3f7622d9e3 fix(switcher): control plane is libreportal in BOTH modes (root was never intended)
Correction from the maintainer: /docker was always libreportal:libreportal;
root:root only ever appeared as an artifact of un-de-sudo'd sudo commands, not
by design. reconcileDockerOwnership now always assigns the control plane to the
manager user regardless of mode (was wrongly root:root for rooted). The deeper
implication — that the de-sudo helpers' rooted=sudo path also re-creates
root-owned files — is being confirmed before realigning.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-24 01:36:29 +01:00