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>
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>
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>
Round-trip test exposed it: during a rooted stint the WebUI (root-in-
container) writes root-owned files into its data dir; back in rootless the
WebUI user (dockerinstall) can't manage them -> container Exited(137).
Since the WebUI is LibrePortal's OWN regenerable 0:0 component, reconcile now
also chowns containers/libreportal to the mode's container owner (root rooted
/ install user rootless). Validated: after this the WebUI returns to HTTP 200.
Third-party app data under containers/ is still untouched (backup/restore).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Mode switches change /docker ownership expectations, but the switcher only
ever fixed the socket — never file ownership — so a rooted<->rootless swap
left the control plane owned for the wrong mode (CLI + de-sudo helpers then
can't access it).
Add reconcileDockerOwnership (single source of truth): swaps ONLY the owner
of LibrePortal's control plane (configs/logs/scripts/DB + /docker top) to the
mode owner (root rooted / manager rootless). It never resets mode bits (only
adds o+x on /docker for traversal and o+r on the DB for the WebUI), and never
touches /docker/containers/** app data, backups/, or ssl/ssh keys. Wired into
both switch branches between container-retag and app-start.
App data is deliberately NOT chowned: container UIDs re-map across modes
(rootless subuid offset), so a chown can't carry e.g. Postgres data across —
that's a backup->switch->restore operation. Switcher now warns to back up
stateful apps before switching and restore after.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Per the confirmed ownership model: files under /docker/containers/<app>/ are
app data owned by the docker install user; everything else is the manager-
owned control plane. createTouch now picks runFileOp vs runInstallOp by the
file's location and creates it directly as the right owner — no more
chown-to-another-user (which needs root the unprivileged runtime lacks).
The $2 user hint is now advisory. (Generator content-writes into
frontend/data still need converting to runFileWrite — next.)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Disabling userland-proxy makes rootless dockerd require br_netfilter
(/proc/sys/net/bridge/bridge-nf-call-iptables), absent in the rootless
netns on Debian -> default bridge creation fails -> daemon won't start.
Drop the daemon.json userland-proxy=false write. Source-IP is preserved
at L7 by Traefik (X-Forwarded-For), so no real loss.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Reinstall test on Debian 12 surfaced three rootless-only breakages (rooted
was byte-identical/fine):
1. pasta blocked by Debian's passt AppArmor profile (DENIED ptrace read ->
can't open container netns -> rootless dockerd never starts). Default
CFG_ROOTLESS_NET back to slirp4netns (reliable); pasta stays selectable
for hosts that relax the profile.
2. de-sudo mis-assigned helpers by owner. /docker management layer (apps DB
chowned to libreportal by install_sqlite, /docker/logs) is MANAGER-owned,
not dockerinstall. Add runInstallWrite; move apps-DB sqlite3 -> runInstallOp
and /docker/logs appends -> runInstallWrite. Revert ownership-SETUP scripts
(libreportal_folders, app_folder) to runSystem — they must run as root to
establish ownership during install. Container files (/docker/containers/<app>)
stay runFileOp.
3. kernel hardening sysctls written to /etc/sysctl/99-custom.conf, which
'sysctl --system' does not read -> never applied. Write them to
/etc/sysctl.d/99-libreportal-hardening.conf instead.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
FIX: dockerCommandRun rooted path is 'sudo $command' (unquoted word-split),
so 'docker ps --format "{{.Names}}"' was passing the format with LITERAL
quotes -> docker emitted '<name>' and the downstream grep never matched
(broken in rooted too). Switch all docker invocations to runFileOp, which
preserves args via "$@" in both modes (and runs as dockerinstall against
the rootless socket). Fixed monitoring.sh, dashy, tags_processor_network_mode.
Convert: jitsimeet (rm/wget/unzip/mv/sed/tee/gen-passwords on /docker ->
runFileOp/runFileWrite), authelia (config sed/mkdir/chmod/chown/secrets tee
-> runFileOp/runFileWrite; docker exec -> runFileOp docker, preserving
--password), reset_git (cp->/root runSystem, install-dir chown runInstallOp;
kept sudo -u manager). check_update/update_git_check need no change (all
sudo -u manager git, already least-privilege).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
- restic_install, crowdsec_update/verify_firewall/fix_priority: pure host
ops (apt/cscli/nft/systemctl, /etc/crowdsec) -> runSystem.
- kopia_backup/borg_restore: ignore-file/target tee+chown+mkdir -> runFileOp/
runFileWrite; kept the 'sudo -E -u dockerinstall' engine calls as-is —
those already run as the unprivileged backup user (least-privilege; the
scoped sudoers will permit (dockerinstall)).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
firewall_initial_setup + firewall_clear_rules (ufw/ufw-docker),
host_access.sh (sshd/-T/-t, /etc/ssh, authorized_keys, systemctl reload),
set_socket_permissions (docker socket test/chmod), and webui_install_systemd
(systemd unit tee + systemctl) -> runSystem. These stay real-root in both
modes and define part of the eventual scoped allowlist. Left the
'sudo -u <manager> crontab' run-as-manager lines for a dedicated pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
- New runInstallOp helper for manager install-dir/template ops (rooted:
sudo; rootless: run as the current manager user, which owns the tree).
- adguard.sh, traefik.sh: container-config sed -> runFileOp.
- crowdsec.sh: host crowdsec systemctl/apt-get -> runSystem.
- dashy_update_conf.sh: conf-file mkdir/chown/md5sum/tee -> runFileOp/
runFileWrite; docker ps/restart -> dockerCommandRun.
Deferred (cross-owner copy / temp-file across /tmp<->/docker, need rootless
env to bridge correctly): owncloud_setup_config.sh, adguard_auth.sh.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>