userdel does NOT remove /var/spool/cron/crontabs/<user>, so across an
uninstall->reinstall the manager's uid can be recycled (e.g. 1001 -> 1003)
while the old spool file stays owned by the dead uid. The spool dir is
sticky (1730), so the new manager can't rename its temp over the
old-uid-owned file → "crontab: crontabs/libreportal: rename: Operation
not permitted", and the crontab silently never updates (the "added"
success message doesn't check the rename). Same class as the stale
easydocker spool left by the pre-rename migration.
Two fixes:
- runFullUninstall removes each torn-down user's cron spool (+ the legacy
easydocker one) so teardown stops leaving orphans.
- initUsers defensively drops a manager cron spool owned by a different
uid (recycled) before the manager-run crontab setup runs — fixes an
already-dirty box and any uid drift, in both modes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
The previous commit handed /docker/containers to the container user but
left /docker itself at initFolders' 750 (manager-only) during the install
— so the container user couldn't traverse INTO /docker to reach its now-
owned containers/, and the boot scan still hit "find:
'/docker/containers/': Permission denied" (the dir's documented rootless
mode is 751, but the reconcile that sets it runs later). initContainerLayer
now adds the o+x traversal bit to /docker (→ 751) alongside the
containers/ handover, so the boot scan can both enter /docker and read
containers/.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Reverts the 2>/dev/null band-aids and fixes the root cause. The
manager-run install boot scans app configs under /docker/containers AS
the container user (runFileOp). But init.sh's initFolders creates that
dir manager-owned, and the handover to the container user happened later
(start_preinstall), AFTER the boot scans — so the scans ran as the
container user against a dir it didn't own yet: "find:
'/docker/containers/': Permission denied" (cosmetic; the dir is empty
that early, but it's the wrong ownership at the wrong time).
Add initContainerLayer() to init.sh's root phase (after initGIT +
initUpdateConfigs, before the manager-run handoff): rootless-only, it
creates the docker-install user if missing and chowns /docker/containers
to it (751). The later rootless setup is now idempotent — it finds the
user existing and just (re)asserts its password + daemon config (moved
updateDockerInstallPassword out of the create-only branch). Rooted is
unaffected (containers stay manager-owned, which the manager reads).
Result: by the time the boot scans run, /docker/containers is owned by
the user doing the scanning — no permission error, nothing suppressed.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
A full uninstall tears down the rootless daemon and removes the
docker-install user's home, which destroys the WebUI image AND the build
cache — so every reinstall's `docker build` runs from scratch (slow,
re-pulls the base image + reinstalls deps). On a slow local box that
dominates the iteration loop.
--skip-docker-images on `init.sh ... uninstall` preserves the rootless
docker layer: it still removes stale containers, the control plane,
manager user, footprint and /docker, but keeps the daemon running, the
docker-install user + home (image/layer cache), and the rootless sysctl
drop-in. The following reinstall then finds rootless already set up and
rebuilds the WebUI image from cache — fast. No effect on install.
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>
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>
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>
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>
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>
init.sh appended 'libreportal ALL=(ALL) NOPASSWD: ALL' straight to /etc/sudoers
— a malformed line there locks out sudo entirely. Move it to a validated
/etc/sudoers.d/libreportal drop-in (visudo -cf before install, 0440 root:root).
The grant is still broad; this is the single managed file we tighten to a
scoped command allowlist once the runtime no longer needs broad root. Only runs
at install, so existing boxes are untouched.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Propagate the ✓ Success / ✗ Error / ! Notice / ❯ Question glyphs (from markers.sh) through the rest of the pipeline: swap the inlined helpers in init.sh and generate_arrays.sh, and replace raw echo -e "${RED}ERROR:${NC}" calls with the isX helpers in config_check_missing.sh, check_success.sh, initilize_files.sh, and reset_git.sh.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Swap the ### hash headers (isHeader) for a ╔═╗ ║ ╚═╝ double-line box and
wrap the LibrePortal logo in a matching 52-wide box. Build the rule with
printf-repeat and fixed pad widths instead of tr/${#} so multibyte box
chars stay aligned regardless of locale. Mirrors the credentials panel.
Applied to all three copies (markers.sh, init.sh, generate_arrays.sh).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Keep just the wordmark + portal; the underline read poorly.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
The raised (‾▔) divider read strangely; go back to the low _▁ step-ticks
the prior look used and restore the leading blank line. Keep the divider
extended to the end of the final letter.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Raise the underline to high marks (‾▔) so it tucks under the wordmark,
extend it to reach the end of the final letter, and remove the leading
blank line so the banner starts flush.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Add a small left gap before the wordmark and a step-tick underline
(_▁ repeated) matched to the logo width.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
The portal between Libre/Portal was a closed ring ("just a circle"); give it
two feet (╨─╨) and a touch more breathing room on each side.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
The startup banner (displayLibrePortalLogo in init.sh/start.sh and the
generate_arrays.sh splash) still rendered the old "EASY DOCKER" figlet art.
Swap it for a LibrePortal wordmark — Calvin S mixed-case "Libre"/"Portal"
with a small framed portal glyph between the two words.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
A free, open, self-hosted app platform (GNU AGPLv3): one-click app deploys,
Traefik reverse proxy with automatic SSL, rootless Docker support, gluetun
VPN routing, and a web dashboard to manage it all.
Free & open forever to self-host; optional paid hosted services fund it.
See PROMISE.md.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>