200 Commits

Author SHA1 Message Date
librelad
96300d5f6b Merge claude/1 2026-05-24 01:51:39 +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
b2d57b2774 Merge claude/1 2026-05-24 01:40:58 +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
82411f00f4 Merge claude/1 2026-05-24 01:36:29 +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
librelad
9ec95657b7 Merge claude/1 2026-05-24 01:28:56 +01:00
librelad
e9bea13d3b fix(switcher): reconcile also flips the WebUI's own (0:0) dir so it survives a switch
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>
2026-05-24 01:28:56 +01:00
librelad
068db94320 Merge claude/1 2026-05-24 01:16:49 +01:00
librelad
1dc915f642 feat(switcher): reconcileDockerOwnership — safe owner-only control-plane reconcile on mode switch
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>
2026-05-24 01:16:49 +01:00
librelad
e7b55be73a Merge claude/1 2026-05-24 00:58:16 +01:00
librelad
3d0570de14 fix(rootless): make createTouch owner-by-location (app=dockerinstall, else manager)
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>
2026-05-24 00:58:16 +01:00
librelad
007b39ea14 Merge claude/1 2026-05-24 00:23:38 +01:00
librelad
affd96fb42 fix(rootless): don't disable userland-proxy (breaks rootless bridge on Debian)
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>
2026-05-24 00:23:37 +01:00
librelad
fa2df5dffe Merge claude/1 2026-05-24 00:12:06 +01:00
librelad
68110d199c fix(rootless): slirp4netns default, manager-vs-container helper split, sysctl path
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>
2026-05-24 00:12:06 +01:00
librelad
4b17efd1b3 Merge claude/1 2026-05-23 23:51:01 +01:00
librelad
f1ce5e3822 harden(desudo): fix docker-cmd helper bug; convert jitsi/authelia/reset_git
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>
2026-05-23 23:51:01 +01:00
librelad
5741fe6ee9 Merge claude/1 2026-05-23 23:48:24 +01:00
librelad
43779a992b harden(desudo): backup engines (restic/kopia/borg) + crowdsec host helpers
- 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>
2026-05-23 23:48:23 +01:00
librelad
1d6e838349 Merge claude/1 2026-05-23 23:46:58 +01:00
librelad
d7c0d12314 harden(desudo): funnel firewall/ssh/socket/systemd system ops through runSystem
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>
2026-05-23 23:46:58 +01:00
librelad
8532aecb3f Merge claude/1 2026-05-23 23:45:42 +01:00
librelad
0c719b5912 harden(desudo): add runInstallOp helper + convert adguard/traefik/crowdsec/dashy
- 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>
2026-05-23 23:45:42 +01:00
librelad
8813ced29e Merge claude/1 2026-05-23 23:38:24 +01:00
librelad
ccbb2e1c47 harden(desudo): convert permission/touch helpers + network-mode processor
app_folder.sh, libreportal_folders.sh, create_touch.sh: chmod/find/chown/
touch on /docker dirs -> runFileOp (dropped nested -exec sudo chmod).
tags_processor_network_mode.sh: awk/tee/mv/cmp/rm/sqlite3 on compose+DB ->
runFileOp/runFileWrite; gluetun docker ps + compose up -> dockerCommandRun.
Deferred (read install-dir templates, need category-3 handling):
copy_file.sh, copy_files.sh, config_scan_variables.sh.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-23 23:38:24 +01:00
librelad
fe01a1947b Merge claude/1 2026-05-23 23:36:13 +01:00
librelad
582664aadf harden(desudo): convert crontab daemon, db-scan, port-allocation subsystems
All operate on /docker data-plane (DB at $docker_dir, compose files,
task dir /docker/.../frontend/data/tasks): sqlite3/find/sed/mkdir/chmod/
chown/mv/rm/mkfifo/truncate/install/tee -> runFileOp/runFileWrite. The
two systemctl enable/start calls in the check processor -> runSystem.
Dropped spurious sudo on text-only echo/grep/date in db_app_scan.
Byte-identical in rooted.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-23 23:36:13 +01:00
librelad
6e0dc04dd7 Merge claude/1 2026-05-23 23:33:51 +01:00
librelad
a8248ccf7f harden(desudo): convert monitoring subsystem + global log-append idiom
- Global uniform pass: the $logs_dir/$docker_log_file log-append idiom
  (always /docker/logs, data-plane) -> runFileWrite -a across runtime
  files (check_success.sh logging backbone + several app scripts).
- monitoring.sh fully converted: containers_dir/docker_dir file ops
  (sqlite3/sed/mkdir/cp/rm/chmod/find, grafana tee-heredocs) -> runFileOp/
  runFileWrite; prometheus/grafana docker ps/kill/restart -> dockerCommandRun.
Byte-identical in rooted (all helpers reduce to sudo there).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-23 23:33:51 +01:00
librelad
03d7a7b969 Merge claude/1 2026-05-23 23:26:13 +01:00
librelad
bdd73b4686 harden(desudo): append-capable runFileWrite + convert config-to-container
Add -a/--append to runFileWrite so the pervasive /docker/logs log-append
idiom (`… | sudo tee -a $logs_dir/$docker_log_file`) routes through the
mode-aware helper instead of raw sudo.

Convert scripts/config/docker/docker_config_to_container.sh fully: all
ops target /docker app config + logs (data-plane), so md5sum/grep/chmod/
cmp/editor -> runFileOp and the log-appends -> runFileWrite -a.
Byte-identical in rooted.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-23 23:26:13 +01:00
librelad
a5cdbc4656 Merge claude/1 2026-05-23 23:22:46 +01:00
librelad
82839abea6 harden(desudo): arg-safe runFileOp + convert DNS subsystem off raw sudo
Give dockerCommandRunInstallUser an --argv mode that execs arguments
verbatim (sudo -u <user> env ... "$@") instead of bash -c "$*", and
point runFileOp at it. The old $*+bash -c re-parse silently mangled
backslashes/quotes in args — e.g. sed scripts (\1, \( become 1, ( ) and
the sqlite3 .backup arg — so rootless data-plane ops with regex were
broken. Verified: the WG_DEFAULT_DNS sed now applies correctly as the
install user. All existing runFileOp callers pass plain commands, so the
switch is safe (and fixes the latent sqlite3 case).

Convert scripts/network/dns/setup_dns.sh: /etc/resolv.conf edits and
ping -> runSystem; the WG_DEFAULT_DNS compose-file sed -> runFileOp.
Byte-identical in rooted; correct in rootless.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-23 23:22:46 +01:00
librelad
5e8e28f33d Merge claude/1 2026-05-23 22:59:18 +01:00
librelad
0bf9c41c51 harden(rootless): offset userns surface with kptr/ptrace/bpf-jit sysctls
Enabling unprivileged user namespaces for rootless widens the kernel
attack surface reachable by unprivileged users (a known source of LPE
CVEs). Pair it with three distro-portable, low-impact sysctls that close
the surfaces those exploit chains rely on: kernel.kptr_restrict=2 (hide
kernel pointers), kernel.yama.ptrace_scope=1 (block cross-process
ptrace), net.core.bpf_jit_harden=2 (harden the JIT). Added as a separate
guarded LIBREPORTAL KERNEL HARDENING block so it's clearly deliberate and
independently idempotent.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-23 22:59:18 +01:00
librelad
6d781b66a8 Merge claude/1 2026-05-23 22:54:55 +01:00
librelad
f65ecfc5ed refactor(config): move CFG_ROOTLESS_NET to its own advanced network_rootless file
Split the rootless network-driver knob out of network_docker into a
dedicated advanced subcategory (configs/network/network_rootless), marked
**ADVANCED** at both the file-header and field level so it's hidden behind
the advanced toggle in the WebUI. Registered in the network .category
order after network_docker. Pure config relocation — the install script
reads CFG_ROOTLESS_NET as a sourced var, so no script change needed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-23 22:54:55 +01:00
librelad
5bf2011b1a Merge claude/1 2026-05-23 22:52:44 +01:00
librelad
829816b826 feat(rootless): default to pasta+implicit, disable userland-proxy, make net driver switchable
Switch the rootless network stack from slirp4netns+builtin to pasta+
implicit (faster and propagates the real client source IP). The earlier
pasta+builtin attempt bricked the daemon because rootlesskit rejects
mismatched net/port-driver pairs; expose a single CFG_ROOTLESS_NET knob
(pasta default, slirp4netns fallback) and derive the matching port
driver in-script so an invalid combo can't be configured. Disable
userland-proxy in the rootless daemon.json (merged, not clobbered) so
containers see the real source IP. Both driver binaries are always
installed, so switching is a config flip + rootless re-setup.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-23 22:52:44 +01:00
librelad
caa197f2fa Merge claude/2 2026-05-23 22:24:43 +01:00
librelad
f6a51f9a19 fix(rootless): run WebUI container as userns-root with socket gid 0
In rootless the container was set to user: <host-uid> (e.g. 1002:1002) with
group_add: <host-sub-gid> (e.g. 166528). Inside the daemon's user namespace
those are out of range — group_add made runc call setgroups() with an unmapped
GID ('setgroups: invalid argument'), so the WebUI container never started.
In rootless the container now runs as 0:0 (userns-root == the install user, which
owns the bind-mounts and the rootless socket) with socket gid 0. Rooted is
unchanged. Verified: libreportal-service comes up and talks to the rootless
socket.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-23 22:24:43 +01:00
librelad
e52938bba5 Merge claude/2 2026-05-23 22:02:43 +01:00
librelad
049d5de6a8 fix(rootless): start daemon with slirp4netns, not invalid pasta+builtin
The rootless dockerd override forced NET=pasta + PORT_DRIVER=builtin, which
rootlesskit rejects ('pasta requires port driver none or implicit'), so the
daemon failed to start every time (the real cause behind 'rootless socket not
found'). Use slirp4netns + builtin (valid, still skips the userspace
port-handler). Verified: daemon now comes up, docker Server 29.5.2 responds.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-23 22:02:42 +01:00
librelad
7a277384f0 Merge claude/2 2026-05-23 21:42:29 +01:00
librelad
49c1a23221 fix(rootless): run install-user commands via sudo -u, not SSH
dockerCommandRunInstallUser ssh'd to <user>@localhost, but nothing set up an
SSH server/keys/authorized_keys, so every rootless setup command (daemon
install, systemctl --user) silently no-op'd. Replace with 'sudo -u <user> env
…' that sets XDG_RUNTIME_DIR / DBUS_SESSION_BUS_ADDRESS / DOCKER_HOST / PATH
explicitly; linger keeps the user systemd + /run/user/<uid> alive so
systemctl --user works. No SSH server, no keys, less attack surface, and
sudo -u to an unprivileged user is not a root escalation.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-23 21:42:29 +01:00
librelad
67cda1c955 Merge claude/2 2026-05-23 21:23:12 +01:00
librelad
90584f0b30 fix(rootless): actually create the docker install user
useradd was missing its login-name argument (and -m), so it failed — silently,
because local result=$(...) swallowed the exit code and checkSuccess reported
success. The rootless install user was therefore never created, which cascaded
into 'invalid user dockerinstall' and a daemon that never came up. Pass the
username + -m (subordinate uid/gid ranges come from login.defs), unmasked.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-23 21:23:12 +01:00
librelad
8f6f4c65ed Merge claude/2 2026-05-23 20:56:56 +01:00
librelad
48d9bd0a13 fix(init): never clobber live config values on deploy/reinstall
setupConfigsFromRepo / sync_configs_from_install used 'cp -a' of the template
over /docker/configs, so any fast/full deploy (which runs init.sh) silently
reset user config to template defaults — e.g. it flipped a live rooted box to
the new rootless template default and broke it. Use 'cp -an' (no-clobber):
fresh installs still get the full template, existing installs keep their values,
and new keys are still added by the add-only reconcile pass. This is also what
makes a rootless template default safe for existing rooted boxes.

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