Move scripts/webui/data/generators/apps/webui_gluetun_providers.sh ->
containers/gluetun/scripts/gluetun_providers.sh and replace the gluetun-specific
gated call in webui_updater.sh with a generic per-app loop: an installed app may
define appWebuiRefresh_<app> (in its scripts/) for data it wants refreshed on
every WebUI update. gluetun provides appWebuiRefresh_gluetun (a thin wrapper over
webuiGenerateGluetunProviders).
- No gluetun-specific code remains in central WebUI code — it's a true drop-in.
- Install gate preserved + generalized: the loop iterates the manager-owned
install templates (listable) and tests each app's live compose directly (works
without list perm on the container-user data dir), so non-users never pay for it.
- webuiGenerateGluetunProviders keeps its name (still called by the installer and
the gluetun_refresh_providers tool); now sourced via the container scan.
- Regenerate arrays (generator drops out of files_webui).
Loop verified with stubs: only installed apps with a defined hook fire; apps
without a hook are skipped; nothing fires when nothing's installed.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Replace the central app-name if-ladder in app_update_specifics.sh with a generic
dispatcher: each app ships containers/<app>/scripts/<app>_update_specifics.sh
defining appUpdateSpecifics_<app> (live-sourced by the container scan, dispatched
by `declare -F` — same pattern as tools). A hook may set shouldrestart=true. Apps
with no specifics ship no hook.
- Move the adguard/pihole (DNS updater), dashy (conf refresh), focalboard (nobody
ownership + restart), and libreportal (webui regen) branches to per-app hooks.
- Move scripts/gluetun/gluetun_route_apps.sh -> containers/gluetun/scripts/
(scripts/gluetun/ removed).
- Move scripts/install/install_crowdsec.sh -> containers/crowdsec/scripts/
crowdsec_install_host.sh; fix the path note in crowdsec.sh.
- Regenerate arrays (moved files drop out; the per-app files are container-scanned,
not arrayed).
Dispatch verified with stubs: adguard/pihole/dashy/focalboard/libreportal behave
identically to the old ladder (incl. shouldrestart propagation), apps without a
hook are a clean no-op. The CLI itself had no per-app branches — app-specific CLI
is already the (now fully modular) tools system.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
The rename was justified partly by an anticipated second `libreportal-regen`
unit — which we then decided not to create (the poll rides the existing task
processor). What's left is cosmetic, and it isn't worth a footprint_version bump
(which forces a root re-install on every existing box) plus the dual-name
migration cruft.
Reverting also means the rename was the ONLY footprint change in the regen work,
so the whole regen system now ships as a plain manager-owned code deploy — no
root re-install needed. footprint_version stays 2.
Kept only the accurate FOOTPRINT.md note that the service also drives the poll.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
The single systemd unit is the task processor (and now also drives the periodic
regen poll), so name it for what it does instead of the ambiguous bare
"libreportal.service" — clearer now that the runtime has more than one concern.
- svc helper: SERVICE_NAME=libreportal-taskprocessor.service; _drop_legacy()
stops/removes the pre-rename unit on install (idempotent migration) so an
upgraded box never runs two processors.
- init.sh: read baked roots from the new unit (fall back to the old name);
uninstall removes both names; bump footprint_version 2 -> 3 (root-owned unit
changed, so a manager-run update flags "root re-install needed").
- check_webui_systemd: accept either name during the transition.
- docs/FOOTPRINT.md: new unit name + uninstall command.
No sudoers change — it allows /usr/bin/systemctl generically, not a named unit.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Add `lpRegen` (scripts/webui/webui_regen.sh) — one entry point that rebuilds the
file-derived artifacts whose sources changed, so callers don't have to know which
generator owns what. Self-heal is a cheap `find -newer` mtime compare (no watcher
/ daemon): a stage runs only when a source is newer than its artifact, or --force.
- `libreportal regen [all|webui|arrays] [--force]` CLI command (new category).
- Task processor idle tick runs a throttled `regen webui` poll, so an app dropped
in out-of-band (drag-drop / marketplace) appears on its own — no manual command,
no inotify (works on the relocatable/external-drive roots where inotify can't).
- make_release.sh guards against shipping stale source arrays (regenerate; abort
if the committed tree was out of date), killing the "forgot generate_arrays" bug
class at the build boundary.
- Document the front door in DEVELOPMENT.md.
webui scope rebuilds from containers/<app>/{*.config,tools/*.tools.json}; arrays
scope from scripts/** (a dev/build concern — a no-op on a normal install). Gate
logic verified in a sandbox (clean/config-newer/tools-newer/force/missing).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Each app now carries everything under containers/<app>/: Tools-tab actions in
tools/ (declaration <app>.tools.json + function <app>_<tool_id>.sh) and logic
helpers in scripts/ (e.g. <app>_auth.sh). The container scan live-sources every
.sh under the app (maxdepth 3, prunes only resources/) and webui_tools.sh
auto-merges the .tools.json, so an app is a true drop-in — no central edit, no
array regen.
- Empty the central webui_tools.sh heredoc; all 34 tools across 11 apps now
come from per-app declarations (verified byte-identical to the old output).
- Retire the orphaned mattermost tool scripts to scripts/unused (there is no
containers/mattermost; its install fn already lived in unused).
- Update the dispatch comment/error path, the auth-adapter doc, and
DEVELOPMENT.md to the new convention.
- Regenerate static arrays (files_app.sh no longer lists app/containers/*).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Establish the self-contained tools convention and prove it on a core app:
- discovery now reads containers/<app>/tools/<app>.tools.json (the tools/ subfolder);
tool functions live at containers/<app>/tools/*.sh, auto-sourced by the container
scan (depth 3) — no scripts/app/ entry, no array regen.
- adguard migrated: its 2 Tools-tab actions (reset_password, apply_dns_updater) moved
to containers/adguard/tools/ + tools/adguard.tools.json, and dropped from the
central webui_tools.sh heredoc. adguard_auth.sh stays in scripts/app/ — it's a logic
helper, NOT a tool (the key distinction: only DECLARED tools move).
Central + per-app styles coexist (pihole etc. still central), so the remaining apps
can migrate one at a time with nothing breaking. Verified: heredoc valid sans adguard,
per-app merge re-adds adguard's 2 tools, scripts array dropped the moved fns.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
webui_tools.sh now merges any containers/<app>/<app>.tools.json into apps-tools.json
(jq, sets .apps[<app>]) on top of the central heredoc. So a dropped-in app — e.g.
from LibrePortal-Infra — registers its own Tools-tab actions WITHOUT editing this
file. Combined with the container scan already sourcing containers/<app>/*.sh live,
an app can now be fully self-contained (install fn + tool fns in <app>.sh + tool
declarations in <app>.tools.json) → true copy-on-top deploy, no array regen, no
central edits. Core apps in the heredoc are unaffected; invalid tools files are
skipped with a notice. Verified the merge (drop-in registers, core preserved).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
getlibreportal (downloads host) + weblibreportal (website) — including the website
Eleventy source and the publish tool functions — now live in the separate
LibrePortal-Infra repo (Webstar/LibrePortal-Infra). They're the project's own
outward-facing hosting, not something users install, so the base stays clean.
Removed from base: containers/{getlibreportal,weblibreportal}, the
scripts/app/containers/<app>/<app>_publish.sh tool functions, and their entries in
webui_tools.sh; regenerated the sourced-file arrays; dropped the dead .gitignore
docroot lines. scripts/release/make_release.sh stays here (it builds the base
release). docs/DEVELOPMENT.md now points publishing at LibrePortal-Infra.
LibrePortal-Infra overlays onto an install and picks up releases/catalogue from the
base tree — see its README.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Surface the publish step through the existing Tools system (apps-tools.json -> Tools
tab + 'libreportal app tool <app> publish'), so the docroot can be (re)built from
the WebUI instead of a manual cd + script.
- webui_tools.sh: declare a 'publish' tool (no inputs) for getlibreportal + weblibreportal.
- scripts/app/containers/getlibreportal/getlibreportal_publish.sh (appGetlibreportalPublish):
runs the host's publish.sh into the served data dir, as the container user (owns it).
- scripts/app/containers/weblibreportal/weblibreportal_publish.sh (appWeblibreportalPublish):
builds Eleventy as the manager (owns the install tree), then syncs the result into
the container-user-owned docroot — handling the build-vs-write owner split.
- Both guard for the build prerequisites (repo source / npm / dist) and fail with a
clear message; regenerated the sourced-file arrays.
Honest status: scaffolding only — wiring verified (dispatch names match, files sourced,
JSON valid) but the end-to-end tool RUN is untested, and it's build-box-only (needs the
repo checkout + npm + a built dist/). These hosting apps are dev-only and headed for a
separate repo; this just sets the automation up so it's ready to iterate on.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
The sha256 only proves a download is intact; a compromised host could swap the
tarball + its checksum. Add minisign signatures, which prove authenticity (the host
can't forge them without the offline secret key). Ships INACTIVE behind a REPLACE_ME
placeholder, so installs work until a real key is generated; then it's REQUIRED.
- make_release.sh: signs the tarball when LP_MINISIGN_SECKEY is set -> <tarball>.minisig.
- libreportal.pub: the public key (placeholder), ships in the tarball and is installed
to the ROOT-OWNED footprint (/usr/local/lib/libreportal/libreportal.pub) by init.sh
-> the manager can't swap it to accept forged updates. footprint_version -> 2.
- install.sh: LP_MINISIGN_PUBKEY constant; once non-placeholder, downloads + verifies
the .minisig (minisign -P) and REFUSES on invalid/missing (auto-installs minisign if
needed). --no-verify-signature is a dev-only escape hatch.
- fetch.sh (update path): verifies against the footprint .pub (minisign -p), refuses on
invalid/missing.
- docs/DEVELOPMENT.md: keygen (minisign -G), paste pubkey into libreportal.pub +
install.sh, keep the secret key offline, sign builds via LP_MINISIGN_SECKEY, bump
footprint_version on key rotation.
Verified end-to-end with a real throwaway key: good signature accepted; tampered,
wrong-key, and missing-signature all refused; placeholder skips (sha256 still enforced).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
A manager-run 'update apply' refreshes code/apps/WebUI but CANNOT rewrite the
root-owned footprint (helpers/wrapper/uninstall/unit/sudoers) — that immutability
is the de-sudo boundary. Previously a release that changed those would silently
leave them stale. Make it explicit:
- init.sh: footprint_version=1 constant, baked at install into
/usr/local/lib/libreportal/.footprint_version (root:root 0644) by initRootHelpers.
Bump it whenever a root component changes.
- make_release.sh: publishes footprint_version in latest.json.
- fetch.sh: lpInstalledFootprintVersion (marker) + lpReleaseLatestFootprint (manifest).
- check_update.sh: 'update apply' REFUSES when the release's footprint_version
exceeds the installed one, directing to a root re-install (which fetches +
re-bakes everything atomically). No half-applied updates.
- webui_system_update.sh: badge sets footprint_update_needed + clears can_update so
the WebUI won't offer a one-click apply for a footprint-bumping release.
- docs/DEVELOPMENT.md: the bump rule + the footprint exception explained.
Verified: manifest carries footprint_version; drift decision correct both ways
(no marker/older -> needs re-install; equal -> no drift).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
The git-era recovery commands now do the right thing in release mode instead of
attempting a clone:
- gitReset (libreportal reset / update reset) and runReinstall (CLI/system reset,
missing-files recovery): a release branch re-fetches the verified tarball via
lpFetchRelease, then refreshes /root/init.sh + ownership.
- the CLI wrapper's clone_and_install (libreportal reset): sources fetch.sh and
re-fetches the release; falls back to directing the user to the install.sh
bootstrap if the helper isn't present.
git/local behaviour unchanged. Wrapper still bakes cleanly (no placeholders left).
Phases A–D (release build, bootstrap installer, fetch abstraction, release-aware
install + update + recovery) are complete and locally verified. Remaining: phase E
(host install.sh + channels + tarballs on get.libreportal.org) and a real fresh
install on a throwaway box.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Make the WebUI updater work off release versions, not git commits, in release mode
(git/local paths untouched):
- webui_system_update.sh: a release branch resolves latest_version from the channel
manifest (lpReleaseLatestVersion), computes update_available via lpVersionGt vs
the local VERSION, reuses the same throttle + the same update_status.json schema
(source="release"); reuses last-known latest when throttled so the badge
doesn't flicker.
- check_update.sh webuiRunUpdate: a release branch version-compares and, if newer,
lpFetchRelease (download + checksum-verify) the new tarball + dockerInstallApp
redeploy + regen. No config-backup dance — lpFetchRelease replaces only the
install tree; configs/logs are in the separate system tree.
Verified against a local server: latest-version read + the no-update / update-
available decision (0.2.0==0.2.0 no; 0.3.0>0.2.0 yes). Remaining: route the
reset/reinstall recovery paths through the release fetch.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Adding scripts/release/ made generate_arrays emit files_release.sh, which would
source the build tool make_release.sh at runtime (it runs git archive at load —
wrong + harmful). Skip release/ in the folder scan (like system/ and unused/),
remove the orphaned files_release.sh, and regenerate the meta-list. fetch.sh stays
sourced; make_release is in no array.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
scripts/source/fetch.sh (sourced at runtime via files_source.sh):
- lpFetchRelease [ver]: resolve channel manifest -> download tarball -> VERIFY
sha256 (refuse on mismatch/absence) -> replace the install tree ( is
code-only now; configs/logs live in the separate system tree, so no backup
dance). Host/channel from LP_RELEASE_BASE_URL/CFG_RELEASE_BASE_URL + channel.
- lpFetchSource: dispatch release|git|local.
- lpVersionGt: numeric dotted semver compare (used by the updater + badge).
init.sh initGIT is now release-aware: the bootstrap (install.sh) stages+verifies
the code and sets LP_ALREADY_FETCHED=1 (skip re-fetch); a direct release run sources
fetch.sh; a bare /root reinstall is directed to install.sh. install.sh exports
LP_ALREADY_FETCHED + LP_RELEASE_BASE_URL on hand-off. validateUnattended already
accepts release (git-url is gated on git mode).
Config: CFG_INSTALL_MODE default -> release, + CFG_RELEASE_BASE_URL / CFG_RELEASE_CHANNEL
(add-only reconcile preserves existing installs' git/local mode).
Verified: lpVersionGt across cases; lpFetchRelease downloads+verifies+extracts a
clean tree against a local server. The updater + reset/reinstall release paths are
phase D.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
scripts/release/make_release.sh builds a versioned, checksum-verified release
artifact from the committed tree via 'git archive' — the basis for installing
LibrePortal without git/auth. Output lands in dist/<channel>/ laid out as the
hosting will serve it (libreportal-<ver>.tar.gz + .sha256 + latest.json), so it
can be served locally for testing via LP_RELEASE_BASE_URL.
.gitattributes marks dev-only trees export-ignore (scripts/unused, scripts/release,
site, .claude, CONTRIBUTING.md, the git meta files) so they never ship; validated
the archive includes init.sh/start.sh/scripts/configs/containers/VERSION and
excludes all of the above. dist/ gitignored.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
sudo_user_name (the real manager — owns the install, runs the runtime, baked as
__MANAGER__ into the root helpers) was hardcoded to 'libreportal'. Make it
configurable, consistent with the relocatable roots:
- --manager-user=NAME flag + LP_MANAGER_USER env (default libreportal); resolved
early in init.sh and in scripts/source/paths.sh (so the standalone processors
get it too), validated as a real Linux username in libreportalValidatePaths.
- Baked everywhere it must be stable: the helpers + CLI wrapper (CHECK_USER now
__MANAGER__, exports LP_MANAGER_USER) via the install-time sed; the systemd unit
exports LP_MANAGER_USER=<manager>. User creation (initUsers), the sudoers
drop-in, and ~35 call sites already used $sudo_user_name, so they follow.
- Fix the stray manager-name literals: install_crowdsec.sh chown, the
check_install_type fallback. (Brand/identity strings like the backup
engine:libreportal tag are left — they're not the username.)
Verified: resolves default/env/flag; wrapper bakes a custom name (admin) with no
placeholders left; validation rejects invalid usernames. The footprint paths
(/etc, /usr/local) stay fixed by design.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
CFG_DOCKER_MANAGER_USER / installDockerManagerUser was a chrooted SFTP file-access
user — unrelated to the LibrePortal control-plane manager (sudo_user_name), and
the source of the 'two managers' confusion. It was permanently-off dead code: the
gate CFG_DOCKER_MANAGER_ENABLED and the CFG_DOCKER_MANAGER_USER/_PASS keys are
defined in no config template, so it never ran. Its SSH-key-management sibling
(unused/ssh_manager.sh) was already retired; admin host SSH access is handled by
the current /ssh page + scripts/ssh/host_access.sh.
Move install_user_manager.sh / uninstall_user_manager.sh / check_manager.sh to
scripts/unused/manager/ (recoverable, matches the graveyard convention — not
deleted, in case the SFTP-user idea is rebuilt cleanly later), drop the two call
sites (start_preinstall.sh, check_requirements.sh), regenerate the arrays.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
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>
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>
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>
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>
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>
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>
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>
tagsManagerUpdateUniversalTag did a bare 'sed -i "$file_path"' — works only
because start.sh runs as root today; under Model-A-as-manager the manager
can't create sed's temp file in the dockerinstall-owned containers dir
(permission denied). Make the in-place edit run AS the file's owner: classify
by path (containers/<app> -> runFileOp, manager configs/templates ->
runInstallOp), like createTouch. The awk read stays unescalated (config/compose
are world-readable). Unblocks running the whole app as the manager for tag ops.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Wireguard standalone touches /etc/wireguard + sysctl exclusively (genuine
root) -> runSystem for all its mkdir/chmod/sed/rm/grep/tee/qrencode. Traefik
dynamic configs live under containers/traefik (docker-install-owned) ->
runFileOp/runFileWrite (whitelist.yml, protectionauth.yml, the router-rewrite
awk|tee|mv in port_subdomains). sudo -u drops left.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
- copy_build_context: rsync/cp/rm -> runFileOp (writes the deployed tree AS the
container owner with --no-owner); drop the now-redundant runSystem chown.
- setup_lock: .setup_complete is in the docker-install-owned frontend/data ->
runFileOp touch/chmod/rm (drop the chown).
- tags_processor_docker_installation 'user:' enable + update_compose_yml
jail.local -> runFileOp (deployed compose/config under containers).
- crontab_clear: clear the manager's own crontab via runInstallOp.
- reinstall: cp init.sh to /root -> runSystem (genuine root path).
- create_successful_run_file: drop the pointless sudo echo -> runInstallWrite to
/docker/run.txt.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
scanConfigsForRandomPassword iterates $configs_dir (manager-owned), so the
placeholder grep/sed/awk on the config file -> runInstallOp. The bcrypt export
log ($containers_dir/bcrypt.txt) is docker-install-owned, so its touch/chmod/
sed/grep/append -> runFileOp/runFileWrite (NOT runInstallOp). Covers all
password_replace*/password_user_replace/password_update_all and bcrypt/*.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
The old copy/move helpers ran 'sudo cp/mv X Y; sudo chown $user_name Y' (root +
arbitrary chown). Rework them to write AS the destination's owner — no root, no
chown — classifying by dest path like createTouch: /docker/containers/<app> ->
runFileOp (docker install user), manager-owned control plane -> runInstallOp.
The $user_name arg is now advisory (the path decides). Covers copyFile/copyFiles/
copyFolder/copyFolders/moveFile; copyResource is always containers -> runFileOp;
createFolders' non-container branch -> runInstallOp; updateFileOwnership (an
arbitrary user1:user2 chown) -> runSystem. Confirmed by callers (containers vs
$docker_dir/backup_install_dir/configs dests). Removes a class of root data ops
+ arbitrary-chown from the runtime.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>