Audit follow-up — after a full-repo sweep, the only remaining functional /docker
refs are intentional (the legacy compat shim + the env-overridden legacy-safe
backend default). Fix the last user-visible/stale ones:
- config-options.js: backup PATH_MODE 'auto' label no longer hardcodes
/docker/backups (the path is relocatable) — describes the behaviour instead.
- config.js / setup-detector.js / webui_install_image.sh: refresh comments that
named /docker to the relocatable system/containers roots.
No behaviour change. Active container app scripts already use $containers_dir;
the remaining /docker hits across the tree are docker-compose.yml filenames,
/var/lib/docker, the docker binary, relative array paths, docs/site, and the
unused/ graveyard.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
backupLocationLocalGuard (engine-agnostic, in location_paths.sh), wired into the
dispatcher before init, readiness, and every backup write (engineInitLocation /
engineEnsureLocationReady / engineBackupApp):
- Filesystem warning: the ownership model chowns the repo to the backup user, which
needs POSIX permissions — warn (non-fatal) on FAT/exFAT/NTFS via findmnt FSTYPE.
- Mount-presence refusal: a location with CFG_BACKUP_LOC_<idx>_REQUIRE_MOUNT=true
(an external/removable disk) is refused when its path isn't on a real mount
(findmnt TARGET is '/' or unknown) — so an unplugged drive never silently fills
the system disk. Opt-in; default false leaves on-disk locations unaffected.
New REQUIRE_MOUNT field documented in the location.config template (location_add.sh)
so it surfaces on the Locations page. Verified: REQUIRE_MOUNT+unmounted refuses;
default allows; non-local no-ops.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Make the three roots selectable at install and bake them into the CLI wrapper
(the last /docker-hardcoded consumer).
- init.sh: --system-dir= / --containers-dir= / --backups-dir= flags (=form keeps
the single-token shift logic), plus --allow-home; LP_*_DIR env also honored.
Re-derives paths after flag parsing.
- libreportalValidatePaths (run only in the install flow): each root must be a
non-root absolute path outside protected system trees; the three must not nest
(except the legacy /docker compat layout); a containers/backups root inside a
human home is refused unless --allow-home (rootless o+x traversal = privacy
trade-off). The root helpers re-check at runtime (defence in depth).
- CLI wrapper: a baked bootstrap (the same __ROOT__ placeholder mechanism as the
helpers) exports LP_*_DIR and derives docker_dir/configs_dir/script_dir; every
/docker literal in the heredoc now resolves from those at runtime. init.sh seds
the placeholders into the root-owned wrapper after writing it.
The scoped sudoers needs no change (it references only the fixed helper paths +
system binaries, never a data root). Custom locations verified end-to-end:
generate+bake the wrapper with /mnt/* roots → syntax OK, no placeholders left,
paths resolve. Live box untouched (wrapper/helpers only change on reinstall).
Phase 3b (external-drive guards) + phase 4 (verify) follow.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Split the single tree into three owner-isolated roots and fix the backup
permission failure (restic, running as the container user, could not write the
manager-owned /docker/backups).
Ownership helper (libreportal-ownership), rewritten for three baked roots:
SYSTEM_DIR (manager) CONTAINERS_DIR + BACKUPS_DIR (container user)
- reconcile now drives each tree to its single owner; backups + the WebUI dir go
to the container user (the actual fix). The container user reaches only the
WebUI bind-mount sources (configs/webui/*) via a scoped _webui_bind_access —
traverse the system root + configs, read configs/webui only, nothing else.
- defence-in-depth: refuse dangerous/relative roots even if mis-baked; new
backups-top action.
Baking: init.sh initRootHelpers now seds __SYSTEM_DIR__/__CONTAINERS_DIR__/
__BACKUPS_DIR__ (alongside __MANAGER__) into every helper at install — the trust
boundary stays root-controlled. svc/socket/appcfg helpers updated to derive from
the baked SYSTEM_DIR; the svc unit now exports LP_*_DIR so the processor resolves
roots authoritatively. A baking-safe '*"__"*' sentinel check survives the sed.
Install/uninstall: initFolders creates the three roots; initContainerLayer hands
containers + backups to the container user; uninstall removes all three
(idempotent on legacy single-tree installs). Remaining functional /docker
literals in init.sh (config reads, setupConfigsFromRepo, uninstall) parameterised.
Compose: the WebUI's two relative ../../configs mounts (the only cross-tree
relative mounts in the tree) are now absolute, filled at generation via a new
CONFIGS_DIR_TAG; CONTAINERS_DIR_TAG likewise for the LP_CONTAINERS_DIR env.
Live box unaffected: installed helpers + the live compose only change on reinstall/
rebuild (both of which fill the tags); the CLI-wrapper heredoc paths are baked in
phase 3.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Introduce scripts/source/paths.sh as the canonical path resolver for three
independently-relocatable roots:
LP_SYSTEM_DIR manager-owned control plane (configs/logs/install/db/ssl/ssh/migrate)
LP_CONTAINERS_DIR container-user-owned live app data
LP_BACKUPS_DIR container-user-owned backup repos (own mount-able)
Roots come from the environment when set (install bakes them; CLI/app inherit
from init.sh), else default to /libreportal-*. A transitional compat default
keeps EXISTING installs (legacy single /docker tree, by config marker) on /docker
until a deliberate reinstall, so deploying this never strands a running box.
- init.sh derives the same vars inline (self-contained for the bare /root/init.sh
reinstall case); paths.sh mirrors it for the standalone task/check processors,
which now self-locate their scripts dir and source it.
- Replace functional /docker literals with the derived vars across runtime,
install, backup, crontab, crowdsec/restic, headscale, and reinstall paths;
clean the inert '== /docker/containers/*' guard fallbacks to the variable form.
- backend: CONTAINERS_DIR now from LP_CONTAINERS_DIR (compose env, filled at
generation via a new CONTAINERS_DIR_TAG), legacy-safe default for un-recreated
containers.
- backup default path falls back to the backups root; exclude paths.sh from the
sourced-file arrays (bootstrap file, sourced explicitly).
The CLI-wrapper heredoc + root helpers still reference /docker; those get baked
in phase 3. No layout/ownership change yet (phase 2).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
The root-owned helpers all live in the same fixed dir, so printing the
full /usr/local/lib/libreportal/... path on each success line was long and
repetitive. Use the bare helper name, matching the error branch below.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
When the task processor service is down the rootless daemon socket is
absent, so the pre-install `docker images` probe printed a raw daemon
connection error to the terminal. The surrounding notices already convey
the meaningful state (service not running → image not setup), so the raw
error was noise.
Capture the probe output and redirect its stderr to libreportal.log
instead of the terminal, keeping the detail for diagnostics.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Same class of bug as the topbar partial: icon and data-file references were
relative (icons/apps/x.svg, data/apps/...), so on deep path routes (/app/<name>,
/admin/config/x) the browser resolved them against the route dir and the SPA
catch-all served index.html with HTTP 200 instead of 404 — broken images and
silently-wrong JSON.
Make every reference absolute (anchored on the quote/backtick so already-absolute
/icons paths are untouched):
- JS: all icons/ and data/ literals + templates across components/utils/system
- html/topbar.html: logo <img>
- generators: webui_config.sh and webui_create_app_categories.sh now emit
/icons/... into apps.json / apps-categories.json (regenerated on install)
- updated the two icon-path comments to match
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
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>
Path-based routes (e.g. /app/<name>) made the relative fetch('html/topbar.html')
resolve to /app/html/topbar.html. The SPA catch-all returns index.html with HTTP
200 instead of 404, so response.ok passed and index.html got injected as the
topbar, leaving #nav-app-center absent -> 'Nav element not found' in setActiveNav.
Make the topbar fetch and the loadConfig fetch absolute, and switch the remaining
relative topbar nav hrefs (index/dashboard/tasks .html) to absolute paths so the
SPA click interceptor routes them instead of doing a real browser navigation.
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>
Early in an install the docker-type config isn't loaded yet, so runFileOp
falls back to the manager, which can't list the container-owned (751)
/docker/containers/ dir. Two best-effort scans then leaked
"find: '/docker/containers/': Permission denied" to the install output
(x3 per run): scan_files.sh's app_configs scan and the application config
reconcile. No app configs exist that early on a fresh install, so the
empty result is correct — just suppress the find stderr (the -print0
output still flows). Cosmetic only; doesn't change what's enumerated once
the config is loaded.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Both used the pre-migration query/.html URL form through navigation that
no longer exists, so they landed on a not-found / wrong page:
- setup-wizard handoffToTasks: navigated to `tasks.html?task=<id>` via the
never-defined window.router, falling back to a *relative*
window.location.href. From any non-root path that resolves under the
current path (e.g. /admin/config/tasks.html → matches the /admin*
route), so the first-install "x of x installing" hand-off hit a
not-found task page. Now navigates to the path-based
`/tasks/all?task=<id>&from=setup` via window.navigateToRoute (absolute
full-load fallback).
- apps-manager getNavigationButton / handleNavigation: the "Install
<Service>" buttons on config requirement fields used
`app.html?app=<name>` with a relative window.location.href; from the
/admin/config/* pages they render on, that resolved to
/admin/config/app.html (wrong route). Now `/app/<name>` via
navigateToRoute.
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>
On a fresh install the requirement checks run before the things they
probe exist, leaking raw command stderr:
- check_install_type.sh: `$( (id -u "$user") )` printed
"id: 'dockerinstall': no such user" to the terminal AND — since id's
error goes to stderr, not the captured stdout — the next line's
`[[ "$ISUSER" == *"no such user"* ]]` could never match, so the
rootless-user-absent branch was dead. Add `2>&1` (matching siblings on
lines 25/31): no leak, and the check now works.
- grep on $sysctl (the rootless marker conf, absent until rootless is set
up) printed "grep: /etc/sysctl.d/99-libreportal-rootless.conf: No such
file or directory". Add -s to the four $sysctl greps
(check_docker_rootless, rootless_start_setup, rootless_docker x2);
"marker absent" is still detected (non-zero exit), just without the
file-not-found message.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
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>