The two docker-type-switcher finds run mid-switch, BEFORE
reconcileDockerOwnership, so containers/ is still owned by the OLD mode's
container user while CFG_DOCKER_INSTALL_TYPE is already the target. A
plain runFileOp resolves to the target user, which can't list the
old-mode-owned (751) dir under rootless — so enumerate as the old-mode
owner instead:
- switchMigrateBackupApps: move the find inside the existing
old_mode/resolveDockerInstallUser window (runFileOp now resolves to the
old owner). It previously ran as the manager and silently enumerated
nothing under rootless, so no app got backed up before the switch.
- dockerSwitcherUpdateContainersToDockerType: take old_mode as an arg,
flip CFG to it only for the find (restore before the per-app socket
scan + restart, which need the new daemon). Callers in swap_docker_type
pass $docker_type. The two former rooted/rootless branches were
byte-identical and are collapsed.
NOTE: the full rooted<->rootless switch round-trip is still unvalidated
on the VM (needs a stateful app + an enabled backup location); this fixes
the container enumeration, not yet the end-to-end migration.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Bare `find "$containers_dir"` runs as the manager, but under rootless
containers/ is dockerinstall-owned 751 (traversable, not list-readable by
the manager) -> "find: /docker/containers/: Permission denied". For the
app-log generator that was cosmetic; for dockerComposeUpAllApps /
dockerComposeDownAllApps it silently enumerates nothing so no apps come
up/down. Route these through runFileOp find (dockerinstall in rootless,
manager in rooted — correct in both). The two docker-type switcher finds
are deliberately left: mid-switch the at-rest container owner can differ
from the target-mode user runFileOp resolves to, so they need mode-aware
handling rather than a blind swap.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
installLibrePortalImageWebUI copies the WebUI template into the
dockerinstall-owned containers/ dir, but on a fresh install the general
traversal/ownership reconcile (fixFolderPermissions -> runOwnership
traversal) runs LATER. So at copy time /docker is still 750
(untraversable by the container user) and containers/ may still be
manager-owned, and the copy fails ("tar: /docker/containers: Cannot
open: Permission denied"), cascading into the WebUI never starting on a
first install. Call fixFolderPermissions first so /docker is +x and
containers/ is owned by the container user before the copy.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Two pre-existing bugs a genuinely-clean rootless install exposes:
copyFolder picked the copy user by destination only: a manager-owned
source (e.g. the install dir) copied into the dockerinstall-owned
containers/ ran the cp AS dockerinstall, which can't read the source ->
"cp: Permission denied". The `local result=$(...)` then masked the
failure (local returns 0) so checkSuccess printed success. This broke
installLibrePortalImageWebUI: the WebUI dir wasn't populated, so
initializeAppVariables couldn't read libreportal.config ("No app name
provided"), compose tags were never substituted, and the WebUI container
couldn't start (user: "USER_DATA"). Fix: when source and destination
owners differ (manager -> container), bridge with a tar pipe — the
manager reads, dockerinstall writes — with pipefail so a read-side
failure is no longer masked.
start.sh created the per-run install log with `sudo touch` (root:root
644) but tee's to it as the manager -> "tee: Permission denied" -> every
install-*.log was empty. Fix: chown the log to the user running the
install so the tee can append.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
initRootHelpers ran inside initUsers, before initGIT copies the repo into
/docker/install — so it read helper sources from a not-yet-populated
$script_dir/scripts/system and skipped all 7 ("Root helper source
missing"). This was masked on every prior install because the old
deploy's `rm -rf /docker` left /usr/local/lib/libreportal/ intact, so the
helpers were simply never reinstalled. A genuine clean install (now that
the deploy uses the full uninstall) exposed it: the runtime ended up with
only the CLI wrapper, the scoped sudoers pointed at missing helper paths,
and the WebUI never came up.
The helpers are only needed at runtime (the install phase uses the broad
install-phase sudo), and nothing between initUsers and initGIT uses them,
so move the call to right after initGIT (before initLibrePortalCommand,
which already installs the wrapper to the same dir post-copy).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
runFullUninstall always prompted for `DELETE LIBREPORTAL`, so it couldn't
be driven non-interactively. Honor the existing global --unattended flag
(init_unattended_mode) to skip the prompt; an interactive `init.sh
uninstall` still requires it.
This lets the deploy helper do a clean teardown (`init.sh --unattended
uninstall`) for a full reinstall instead of `rm -rf /docker`. The brute
wipe left the task-processor systemd service running against a deleted
runtime dir; init.sh's idempotent service setup then saw an unchanged
unit and skipped the restart, so the reinstalled WebUI container was
never started. The uninstall stops the service and tears down the
rootless daemon + users in order, so the follow-up install behaves like
a true first install.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
The install hands the heavy setup to the manager (completeInitMessage:
sudo -u libreportal 'libreportal run install') — creating the
docker-install user, rootless setup, apt, sysctl — which needs broad root.
initUsers was installing the SCOPED sudoers up front, so that handoff died
with 'sudo: a password is required' on useradd. Fix: initUsers installs a
temporary NOPASSWD: ALL for the install phase; completeInitMessage calls
the new initScopedSudoers to tighten to the runtime allowlist only after
the install succeeds (on failure, broad sudo is left so the manual
'libreportal run install' retry works). This restores the documented
'kill NOPASSWD:ALL AFTER the runtime is set up' ordering.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
The validation teardown left /home/libreportal orphaned: userdel -r skips
the home when the user still has a live session/processes, and the manager
only got a pkill (not a loginctl terminate) before userdel. Now both users
get disable-linger + terminate-user + pkill before userdel -r, plus an
explicit rm -rf /home/<user> backstop.
Signed-off-by: librelad <librelad@digitalangels.vip>
A single 'sudo bash init.sh uninstall' that permanently removes the whole
LibrePortal footprint, behind a typed 'DELETE LIBREPORTAL' confirmation:
- stops + removes the task-processor service
- best-effort graceful container removal, then tears down the rootless
docker setup + the install user's session (linger/terminate/pkill)
- removes the out-of-/docker footprint (/usr/local/lib/libreportal +
/usr/local/bin/libreportal, /etc/sudoers.d, the systemd unit, the
sysctl drop-ins, restic/kopia/ufw-docker, /root/init.sh)
- rm -rf /docker
- removes the libreportal + dockerinstall users + subuid/subgid ranges
Runs as root (the entrypoint root-check enforces it — and the scoped
sudoers can no longer self-remove anyway); self-contained (only init.sh's
inline helpers, so it works as it deletes /docker); ordered so containers/
daemon stop before the users are removed. Leaves docker/compose/apt deps
and SSH config in place (no lockout). Mirrors FOOTPRINT.md.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Organise the system footprint outside /docker:
- All LibrePortal executables now live together in /usr/local/lib/libreportal/
(root:root): the 7 root helpers AND the CLI wrapper. /usr/local/bin/libreportal
becomes a symlink onto $PATH. run_privileged._runRootHelper, init.sh
(initRootHelpers + scoped-sudoers Cmnd_Alias + command setup) all point there.
The wrapper is now root-owned too (manager can't tamper with its entrypoint).
- Fix a real bug: rootless sysctl settings were written to /etc/sysctl/99-custom.conf,
a dir does NOT read, so net.ipv4.ip_unprivileged_port_start /
kernel.unprivileged_userns_clone never persisted across reboot. Moved to
/etc/sysctl.d/99-libreportal-rootless.conf (the existing
reload now actually applies them). Consistent libreportal* naming.
- Drop dead fqdn_file=/root/libreportal-fqdn.txt global (never used).
- Add FOOTPRINT.md: a manifest of every file LibrePortal places outside /docker
(doubles as an uninstall checklist).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Bring the remaining deferred subsystems under the scoped sudoers, and drop
the one that's redundant.
Backup engines + app configs -> root-owned helpers (same pattern as
ownership/dns/ssh/socket/svc):
- scripts/system/libreportal-bininstall: install <restic|kopia> — does the
whole pkg-manager/signed-download install itself for a fixed, validated
engine name (no blanket sudo apt-get/install). restic_install/kopia_install
call it.
- scripts/system/libreportal-appcfg: {adguard-auth <user> <bcrypt>|
crowdsec-priority|owncloud-config <public> <host> <ip> <public_ip>} —
faithful ports of the AdGuard yaml / CrowdSec bouncer / ownCloud config.php
rewrites, fixed paths + validated args. adguard_auth/crowdsec_fix_priority/
owncloud_setup_config call it.
- run_privileged: runBinInstall / runAppCfg; init.sh installs + allowlists both.
Retire standalone (host-level) WireGuard — it's a duplicate of the
containerized containers/wireguard app (+ headscale mesh), its slirp4netns
speed rationale is largely moot with a better rootless net backend / typical
WAN-bound throughput, and it was the heaviest host-root subsystem (apt +
sysctl + iptables + /etc/wireguard), the worst fit for the rootless/
least-privilege direction:
- moved scripts/wireguard/ + manage_wireguard.sh + check_wireguard.sh to
scripts/unused/; dropped the install-path call, the Tools menu 'w' entry,
and the requirement check; removed the half-built libreportal-wg helper.
- generate_arrays.sh now also skips system/ (root-owned helpers, never
sourced); arrays regenerated (files_wireguard.sh pruned).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
installLibrePortalImageWebUI copyFolder's the template docker-compose.yml
(raw #LIBREPORTAL|TAG|VALUE placeholders) over the runtime one on every
WebUI build — including rebuilds/updates. On a fresh install the following
dockerInstallApp substitutes them, but on a rebuild (libreportal already
installed) nothing did, so the at-rest compose kept raw placeholders and a
plain 'docker compose' against it failed ("invalid boolean:
HEALTHCHECK_DATA", etc.) — it only worked because up_app.sh self-heals at
CLI start time. Re-run the tag processors (initializeAppVariables +
dockerConfigSetupFileWithData, the same heal up_app.sh uses) right after
the copy when libreportal is already installed, so the runtime compose is
always fully substituted at rest.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Replace the NOPASSWD: ALL drop-in with a validated, scoped grant:
- (dockerinstall) NOPASSWD:SETENV: ALL (data plane; rootless-confined)
- (root) NOPASSWD: the 5 root-owned /usr/local/sbin/libreportal-* helpers
+ a fixed system-binary allowlist (systemctl/ufw/ufw-docker/nft/sysctl/
loginctl/service)
No bash/su/tee/cp/chmod/chown/sed/mv/rm/install — none of the
root-equivalent primitives. Also: drop '-G sudo' from the manager useradd
(privileges come from the user-specific drop-in, not group membership),
and defensively remove legacy broad grants on re-run (a NOPASSWD: ALL line
appended to the main /etc/sudoers + sudo-group membership).
Validated live end-to-end as the manager: app lifecycle, webui generate,
ownership reconcile, ssh/dns/socket/svc helpers, task service, data-plane
drop (incl. -E for backups) all denial-free; sudo bash / sudo cat shadow /
arbitrary sudo chown all denied.
Residual (still raw runSystem file-primitives, denied under the scoped
grant until they get helpers / docker-exec rework): owncloud/adguard/
crowdsec app-config edits, wireguard-standalone, restic/kopia binary
self-install. These are opt-in/deferred features.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
start.sh sources init.sh for its function defs at runtime (Model A). The
top-level install-mode auto-detect + initUpdateConfigOption write ran on
every source, rewriting CFG_INSTALL_MODE via 'sudo sed' on the
manager-owned config — denied under the scoped sudoers (the last
per-command 'a password is required'), and spurious '"Auto-detected ..."'
noise. Gate both on BASH_SOURCE==$0 (executed directly only); also drop
the needless sudo from initUpdateConfigOption (config is manager-owned).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
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>