Polish pass for the migration system. Two concrete additions; the live-mirror
and full drift-verify ideas from the original plan are intentionally
deferred — both need real-world test data to land correctly, and the kernel
already exposes everything they'd need.
Per-app migrate hooks (scripts/migrate/migrate_hooks.sh):
Apps can declare two optional functions in their tools.sh (already
auto-sourced per [[libreportal-modular-app-tools]]):
<app>_migrate_pre() — runs before stop+wipe
<app>_migrate_post() — runs after restart, before the user sees it
Each receives:
$1 = source identifier (peer name or backup-tag hostname)
$2 = transport ("restic" | "direct-ssh")
migrateRunHook() is now called from both migration apply paths:
- migrate_apply.sh (restic-mediated, shared backup channel)
- peer_pull.sh (direct-SSH, peer-shell stream)
Use cases: rotate federation keys after a Mastodon move, regenerate
OIDC client secrets, drop SaaS-style locks, fix hostname-baked configs
the URL-rewrite layer doesn't cover.
Hooks are optional — apps without them inherit the standard flow.
Failed hooks emit a non-fatal notice (the rest of the migrate still
reaches 'done') so a single bad hook can't strand an otherwise-working
app in stopped state.
Peer friendly-name overlay (Migrate tab):
Was deferred from Phase 2 because it required Phase 3's UI to feel
cohesive. BackupPage.refreshAll() now also fetches peers.json and builds
a hostname → peer-name lookup. renderMigrate() shows
'homelab (host: homelab.lan)'
for any backup-channel peer that matches the source host, and falls back
to the bare hostname when no peer is defined. Same data, friendlier UI.
Skipped (genuinely deferred, not just out of time):
- Live mirror / warm-standby (continuous one-way sync). Needs a scheduler
+ drift-state to track. Right place for it is a separate feature on top
of the existing kernel rather than bolted onto migrate.
- Drift-verify ("what would change if I migrated?"). Cheap to write but
needs a real cross-host pair to validate against — adding it untested
would just be theatre.
Signed-off-by: librelad <librelad@digitalangels.vip>
End-to-end direct-ssh-direct: two LibrePortal instances exchange pairing
tokens, each authorizes the other to call a locked-down peer-shell dispatcher
via SSH forced-command, then either side can pull live app data from the
other without needing a shared backup repo.
Push and Connect-via-relay are deferred — push is symmetric to pull (same
forced-command, opposite verb), and the relay variant waits for Connect to
actually exist (config_json + kind enum already future-proofed in Phase 2).
Key generation (peer_key.sh):
One ed25519 keypair per install at ~<manager>/.ssh/libreportal-peer{,.pub}.
Generated lazily on the first peer-related call. Used as our outbound
SSH identity AND as the pubkey other instances authorize.
Forced-command dispatcher (peer_shell.sh):
Standalone script, deployed by peerInstallShell() to
~<manager>/.local/bin/peer-shell. authorized_keys entries look like:
command="~/.local/bin/peer-shell <peer-name>",no-pty,no-port-forwarding,
no-X11-forwarding,no-agent-forwarding,no-user-rc ssh-ed25519 AAAA… peer:<name>
sshd hands us $SSH_ORIGINAL_COMMAND; we parse, whitelist the verb, and
refuse anything else. Verbs:
ping Liveness probe (JSON ok:true).
list-apps JSON {peer, apps:[{slug, size_kb}]}.
stream-app tar of containers_dir/<slug> to stdout (slug strictly
validated — lowercase alnum+dash; rejects path traversal).
Audit log appended to ~/.local/state/libreportal/peer-shell.log. Excluded
from the generated source arrays (would crash any sourcing shell on empty
SSH_ORIGINAL_COMMAND); generate_arrays.sh skip-list extended.
Pairing token (peer_pairing.sh):
Format: lp-peer|v1|<name>|<user>|<host>|<port>|<base64-pubkey>|<fingerprint>
Pipe-delimited because the SHA256 fingerprint and base64 pubkey both
contain ':'. peerPairingParse decodes + re-derives the fingerprint from
the actual key, refusing tokens with mismatched fingerprints (catches
truncation / tampering). peerPairingAccept:
1. Installs peer-shell (peerInstallShell).
2. Appends to authorized_keys with the lockdown options above.
3. Inserts a peers row (kind=direct-ssh-direct, config carries host,
port, user, fingerprint).
Symmetric — user runs accept on BOTH sides with the other's token to
enable bidirectional calls.
Outbound SSH (peer_remote.sh):
peerExec <name> <verb> [args] — looks up the peer's connection config and
ssh's in with the right key, BatchMode + ConnectTimeout + accept-new for
the host key. peerPing wraps it and updates peers.status + last_seen.
Pull-an-app (peer_pull.sh):
peerPullApp <peer> <app> [--no-pre-backup] [--keep-urls]
1. peerPing (refuse if unreachable).
2. migratePreBackupDestination (reuses the Phase 0 safety wrapper —
same restic-tagged pre-migrate snapshot as the backup-channel flow).
3. Stop + wipe destination's app folder.
4. peerExec stream-app | tar -x (pipefail; bails on partial transfers).
5. migrateApplyUrlRewrite + dockerComposeUpdateAndStartApp install
(URL repointing, idempotent install path).
6. dockerComposeUp + post-restore hooks.
Identical Stage-2..6 to migrateApplyApp; only the data source differs
(tar-over-SSH instead of restic-restore).
CLI (cli_peer_commands.sh + header):
libreportal peer token — emit this host's pairing token
libreportal peer pair <token> [name] — accept a token (override name)
libreportal peer apps <peer> — live peer-shell list-apps
libreportal peer pull <peer> <app> [--no-pre-backup] [--keep-urls]
WebUI (/peers):
Header gains 'Show my token' and 'Pair with token' buttons (both open
modals around the matching CLI verbs). Token modal warns the user that
the token is credentials. Pair modal accepts a free-form override name.
Direct-SSH peer cards gain a 'List apps' button that opens an inline
drawer showing the peer's live app inventory (via peer apps) with per-
app 'Pull' buttons. Pull modal has the same two safety toggles as the
Migrate tab (pre-backup ON, URL rewrite ON by default).
Backup-channel manual-add modal kept; direct-SSH must use the token flow.
Smoke-tested:
- All 16 peer-subsystem functions register without crashing the shell.
- peer-shell ping ⇒ {ok:true}; unknown-verb refused; path-traversal slug
refused; valid-slug streams.
- Token emit→parse round-trip preserves every field; garbage rejected
with not-a-token; v99 rejected with unsupported-version.
Signed-off-by: librelad <librelad@digitalangels.vip>
The standalone WireGuard install used to flip net.ipv4.ip_forward by
appending+uncommenting `/etc/sysctl/99-custom.conf` via blanket sudo
(sudo tee, sudo sed, sudo sysctl -p). Two problems with that on a
de-sudoed manager:
- The path is non-standard. The conventional location is
/etc/sysctl.d/*.conf (drop-ins, loaded by sysctl --system) — the
old file may not even exist, leaving forwarding silently off.
- `sudo tee /etc` and `sudo sed -i /etc` are not in LP_SYSTEM. The
manager has lost the broad sudo it once had, so this would now
fail outright on every wireguard install.
Add a `wireguard-ip-forward` action to libreportal-appcfg that:
- writes /etc/sysctl.d/99-libreportal-wireguard.conf (a drop-in we
own and rewrite idempotently), and
- reloads via `sysctl --system` (with a `sysctl -p <dropin>` fallback).
containers/wireguard/wireguard.sh now calls `runAppCfg wireguard-ip-forward`
through the existing helper-dispatch path — the whole edit runs as root
in one validated step, no `sudo` in the per-app script.
Same de-sudo pattern as adguard-auth / crowdsec-priority / owncloud-config
already use.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Sweep of every containers/<app>/<app>.sh after the install-side fix that
went into config_file_setup_data.sh — these were the same class of bug:
bare `sudo sed -i` / `sudo docker exec` calls left over from when the
manager carried NOPASSWD:ALL. After the rootless+de-sudo hardening (Model
A, sudoers scoped to LP_HELPERS + LP_SYSTEM only) those calls fail at
runtime, so every per-app routine that uses one would refuse on install
or in its post-install tweak step.
Each call routes through the existing `runFileOp` shim, which picks the
right path per CFG_DOCKER_INSTALL_TYPE (dockerinstall in rootless, manager
in rootful) — same pattern setup_dns.sh / authelia.sh / config_file_setup_data.sh
already use.
Fixed:
gitea.sh:65 — sync GITEA_METRICS_TOKEN into prometheus-scrape.yml
owncloud.sh:88 — fill OWNCLOUD_SETUP_* in the setup-webform html
searxng.sh:87 — flip simple_style: auto → CFG_SEARXNG_THEME
trilium.sh:89 — rewrite trilium-data/config.ini port=
bookstack.sh:139 — bookstack:create-admin via `docker exec`
bookstack.sh:148 — admin@admin.com cleanup via `docker exec ... tinker`
`bash -n` clean on every touched file. Untested live (none of these apps
are installed on the verify VM) but mechanically equivalent to the
already-validated config_file_setup_data.sh fix.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
A peer is a named reference to another LibrePortal instance. Phase 2 only
implements kind=backup-channel (friendly label over a hostname that shows
up in a shared backup repo); direct-ssh-direct and direct-ssh-via-relay
(Connect's blind-relay) are reserved enum values for Phase 3.
DB schema (db_create_tables.sh):
CREATE TABLE peers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
kind TEXT NOT NULL DEFAULT 'backup-channel',
config_json TEXT NOT NULL DEFAULT '{}',
status TEXT DEFAULT 'unknown',
last_seen TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
+ indexes on name and kind.
config_json is kind-specific so new transports don't need a schema
migration. For backup-channel it carries {"hostname":"","loc_idx":N}.
Bash module (scripts/peer/):
peer_helpers.sh _peerDb, peerSqlEscape, peerValidateName/Kind.
peer_add.sh peerAdd <name> <kind> [k=v ...] → INSERT, refresh
generator. Rejects unimplemented kinds early so users
don't create dead-end peer records.
peer_remove.sh peerRemove <name> → DELETE.
peer_list.sh peerList → JSON array; peerGet, peerNameForHostname
(reverse-lookup for the migrate-tab overlay).
peer_check.sh peerCheckReachable, peerCheckAll. For backup-channel
'reachable' = at least one snapshot from that hostname
visible in (preferred|any enabled) location. Updates
status + last_seen so UI dots render without re-probing.
CLI (scripts/cli/commands/peer/):
libreportal peer list
libreportal peer get <name>
libreportal peer add <name> backup-channel hostname=<host> [loc_idx=<n>]
libreportal peer remove <name>
libreportal peer check [name]
Auto-routed by cli_initialize.sh's category-discovery.
WebUI data generator (scripts/webui/data/generators/peers/webui_peers.sh):
Emits data/peers/generated/peers.json with the peerList output and a
generated_at envelope. Hooked into webuiLibrePortalUpdate alongside the
backup generators.
Frontend:
- New top-level /peers route in spa.js (PeersPage class, peers-content.html).
- 'Peers' nav item in the topbar between Backups and the right-side controls.
- Add-peer modal with friendly-name + kind + hostname + preferred-location
selector (populated from the existing backup-locations data).
- Per-peer card with status dot, last-checked time, Check + Remove buttons.
- Phase 3 kinds appear in the kind dropdown as disabled options so users
can see what's coming.
Source-array wiring:
- generate_arrays.sh auto-created files_peer.sh from the new peer/ dir.
- cli_files.sh + app_files.sh include ${peer_scripts[@]} alphabetically.
- files_webui.sh auto-picked-up the new peers/ generator subfolder.
The migrate-tab friendly-name overlay (use peer names in /backup/migrate
when a peer record exists for a hostname) is intentionally deferred — it's
a 5-line frontend lookup once peers.json is loaded; cleaner to add after
Phase 3 ships its peer-detail view.
Signed-off-by: librelad <librelad@digitalangels.vip>
Phase 1 of the migration-system refresh. Surfaces Phase 0's kernel
(libreportal restore migrate ...) as a WebUI flow so users don't have
to drop to the CLI to pull an app from a peer's backups.
backend / data generator:
scripts/webui/data/generators/backup/webui_backup_migrate.sh
Walks every enabled backup location, lists every (other_host, app)
pair with snapshot count + latest id/date, and emits a single
destination summary block (installed apps, running apps, disk free)
so the frontend can compute collisions and warnings without per-row
API round-trips. Filters out our own hostname — we don't migrate to
ourselves. Output: data/backup/generated/migrate.json.
Hooked into the standard webuiLibrePortalUpdate refresh pipeline,
so 'libreportal regen webui' (and the periodic task-processor poll)
keep it fresh on their own.
frontend:
- New 'Migrate' sidebar tab on /backup, sits between Locations and
Configuration. Path-based URL: /backup/migrate.
- Per-source-host cards listing every available app, with snapshot
count + relative-time hint, collision dot when the app is already
installed here, and per-app + per-host migrate buttons.
- Confirm modal with two checkboxes matching the kernel's defaults:
[✓] Back up the destination's existing copy first (pre-migrate
backup; auto-disabled when there's nothing to back up)
[✓] Rewrite host-bound URLs to this host (URL rewrite
— uncheck only to keep source hostnames)
On confirm, runs 'libreportal restore migrate app/system …' via the
task system; opt-out checkboxes append --no-pre-backup / --keep-urls
only when the user un-ticks, matching the kernel's default-on flags.
- Empty state when no other hosts have visible backups, explaining
the shared-backup-location prerequisite.
The CLI dispatcher hooks (Phase 0) wire restore migrate app/system to
migrateApplyApp/migrateApplySystem, so the WebUI gets pre-backup safety,
URL rewrite, and structured progress (when --json-progress is set; not
needed here yet — the task system's log tail is enough for v1).
Signed-off-by: librelad <librelad@digitalangels.vip>
I skipped focalboard earlier citing "DB+files overlap" (sqlite lives in the same
dir as the file-capture target). But linkding / vaultwarden / headscale all have
that exact same shape and we just labeled them in 12b4d68. gitea has had it for
ages and it's proven — the DB dump excludes the raw .db from the snapshot, the
file-capture grabs the dir (incl. live sqlite), and restore replays the dump over
the captured tree. The torn live-sqlite copy is harmless bloat.
So focalboard gets the same treatment for consistency. Coverage now: 9 apps
(adguard set aside, jellyfin still pending DB declaration).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Now that uid auto-discover is in (d424473), adding more apps is just naming the
container + path — no uid guessing. Four more apps get complete backups of their
private data dirs (previously: incomplete on the file side because libreportal
can't read sub-UID-owned content from the host).
- linkding-service -> /etc/linkding/data (overlaps with sqlite db, same pattern as gitea — dump replays over the captured tree on restore, harmless)
- vaultwarden-service -> /data (same overlap pattern)
- headscale-service -> /var/lib/headscale (same overlap pattern)
- mastodon-service -> /mastodon/public/system (uploads; postgres handled separately by backup.db)
Coverage now: nextcloud, bookstack, gitea, owncloud, linkding, vaultwarden,
headscale, mastodon. Skipped jellyfin — it has multiple internal sqlite DBs and
no backup.db declared; adding just backup.files without backup.db / backup.live
wouldn't activate live capture, and adding backup.live blind could yield torn
sqlites. That one wants proper DB declaration first.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
The hardcoded uid:gid in libreportal.backup.files labels was brittle: matched the
default PUID in the compose, but a PUID change (or new image version) would drift
silently and the next restore would chown to a stale owner. Make it impossible to
drift by letting the engine learn the uid at capture time.
backup_files.sh:
- After a successful tar capture, run `stat -c '%u:%g'` inside the container and
write the result to a <host_subdir>.lp-owner sidecar in staging. The sidecar
rides in the snapshot alongside the captured tree.
- Restore reads it back when the descriptor doesn't pin uid:gid; falls back to
0:0 with a clear notice if missing.
- The 5-field form (with explicit uid:gid) is still supported as an override; it
wins and skips the sidecar write entirely.
Update all 4 current labels to the new 3-field form
"<container>:<container_path>:<host_subdir>" (nextcloud, bookstack, gitea,
owncloud). Engine handles both formats during the transition.
Verified with stubs: 3-field capture writes the sidecar with the discovered
33:33; restore reads it back; 5-field override correctly skips the sidecar
write. backup_files.sh parses.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Three closeouts in one pass:
1. DEVELOPMENT.md — consolidated hook-conventions table covering all 8 per-app
hook types (tools / update-specifics / compose-tags / webui-refresh / the two
traefik markers / the two network-provider hooks). One place to look instead
of inferring from the codebase.
2. Nextcloud APCu wired alongside Redis: appUpdateSpecifics_nextcloud now sets
memcache.local=\OC\Memcache\APCu too (was deferred from the fpm switch). APCu
= cheap in-process cache; the fpm-alpine image ships the extension. CLI mode
may emit a harmless "no memory cache" notice on `occ` runs — Nextcloud is
graceful, the FPM worker still uses APCu fine.
3. Container-side file-capture rollout to 3 confident cases:
- bookstack: lscr.io/linuxserver/bookstack with PUID=1000 → /config (1000:1000)
- gitea: gitea/gitea with USER_UID=1000 → /data (1000:1000)
- owncloud: owncloud/server (Apache/PHP) → /mnt/data (33:33, www-data)
Snapshots are now complete for these (the dir's excluded from the raw restic
pass and captured live through the container as a tar → libreportal-owned
staging, same proven pattern as Nextcloud). Less-evidenced candidates left
for live verification: linkding, mastodon, jellyfin, trilium, focalboard,
invidious, vaultwarden, headscale-service — each needs its in-container uid
confirmed before labeling (wrong uid won't break backup, but restore would
chown to the wrong owner).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
_focalboardSqlite called sqlite3 on /data/focalboard.db, but the compose mount
puts the DB at /opt/focalboard/data/focalboard.db (mount: ./data:/opt/focalboard/data).
/data inside the container isn't mounted, so every auth tool (set/reset password,
create/delete user, set admin) silently failed against a nonexistent file.
The memory flagged this as a "DB not persisted" bug, but the compose mount was
already corrected at some point; the auth adapter was the half that didn't get
the fix. Backup label was also already correct (data/focalboard.db relative to
the live app dir resolves to the same file via the mount).
One-line path correction.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Drop Apache+mod_php for the actual performance win — nginx + PHP-FPM — without
the LinuxServer image cascade (custom auto-install, /custom-cont-init.d, abc-vs-
www-data rewrites in the auth adapter + every tool, HTTPS-by-default quirks).
The official fpm-alpine image keeps env-var auto-install and the www-data user,
so the auth adapter, all tools, and the compose-tags hook keep working unchanged.
- Compose: nextcloud-service is now fpm-alpine (still container_name=nextcloud-
service so docker exec ... nextcloud-service php occ in the auth adapter is
untouched). New nextcloud-web nginx sidecar serves :80 over the shared ./html
volume, terminating FastCGI to nextcloud-service:9000. Traefik labels + PORTS_
TAG_1 move to nextcloud-web (the HTTP face); backup.files stays on -service
(the file-owning brain). nextcloud-db + nextcloud-redis unchanged.
- resources/nginx.conf: Nextcloud's recommended nginx config, trimmed for
behind-Traefik (no TLS), large-upload + caldav/carddav/.well-known redirects.
- scripts/nextcloud_update_specifics.sh: NEW post-install hook —
appUpdateSpecifics_nextcloud waits for first-boot occ install to complete
(config.php + occ status=installed), then wires Redis as memcache.distributed
+ memcache.locking via occ config:system:set. Idempotent.
Auto-install is unchanged (official image's NEXTCLOUD_ADMIN_USER + MYSQL_* env
flow). Redis caching now actually USED by Nextcloud (previously the container
was up but config.php had no memcache config). Container-side backup capture
still the right answer for the perm boundary — image change doesn't affect it.
Verified statically: yaml structure, hook parses + dispatches + has the right
graceful-timeout fallback when occ isn't reachable. Live verification (sync
performance + actual Redis hit rate + traefik proxy of FastCGI) needs a fresh
install on a throwaway box.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
If it's gluetun code, it lives with gluetun. Both functions in
scripts/config/tags/processors/tags_processor_network_mode.sh manipulate gluetun
markers / gluetun's compose, so move them into containers/gluetun/scripts/
gluetun_network.sh and rename to the per-app-hook convention:
tagsProcessorNetworkMode -> appNetworkApplyMode_gluetun
tagsProcessorGluetunForwardedPorts -> appNetworkRegisterPorts_gluetun
Central call sites are now provider-agnostic — no "gluetun" literal anywhere:
- docker_config_setup_data.sh: an app routing via CFG_<APP>_NETWORK=<provider>
triggers `appNetworkApplyMode_<provider>` + `appNetworkRegisterPorts_<provider>`
via declare -F, so any future gateway provider plugs in with no engine edits.
- uninstall_app.sh: loops every `appNetworkRegisterPorts_*` hook (each self-skips
when its provider isn't installed), so removing a routed app refreshes the
right provider with no provider name in central code.
Delete tags_processor_network_mode.sh; regenerate arrays. Verified with stubs:
default mode no-ops, gluetun-routed app fires both hooks, gluetun itself is
skipped, unknown provider is silently no-op, uninstall loop calls registerPorts.
Drive-by cleanup: 9 stale "${X_scripts[@]}" array references in app_files.sh /
cli_files.sh (gluetun + headscale from this session's moves, plus 7 pre-existing:
command/ssl/swapfile/ufw/ufwd/user — all from older refactors that left them
behind). Each expanded to nothing at runtime (harmless), but they're dead
misleading refs. Cleaned both files; every remaining array ref now points to a
real files_*.sh.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Caught in the final review — config-options.js referenced the pre-rename function
name. Comment-only fix.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
dockerCheckAllowedInstall was a one-app `case` whose only active caller was the
wireguard app itself — so inline its check (abort if a host WireGuard exists at
/etc/wireguard/params, which would collide on the wg kernel module + UDP 51820)
directly into containers/wireguard/wireguard.sh and delete
scripts/docker/app/checks/allowed_install.sh.
The protection is unchanged; wireguard is now fully self-contained and the last
app name leaves central install code. Regenerated arrays. (The only remaining
dockerCheckAllowedInstall references are in scripts/unused/ — retired apps,
never sourced.)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Last app-specific bits out of central infra (from the per-app audit):
- traefik middleware: replace the hardcoded onlyoffice/owncloud exclude-list +
onlyoffice-headers special-case (in traefik_middlewares.sh AND
traefik_port_middlewares.sh) with two per-app hooks an app ships in
containers/<app>/scripts/<app>_traefik.sh:
appTraefikSkipsDefaultMiddleware_<app> (marker: opt out of default@file)
appTraefikExtraMiddlewares_<app> (echo extra middleware entries)
onlyoffice defines both; owncloud defines the skip marker. Two narrow hooks
(not one clever one) so behavior — incl. the different onlyoffice-headers
ordering between the two files — is preserved exactly. Verified with stubs:
identical middleware strings across normal/onlyoffice/owncloud × authelia/wl.
- moneyapp: add a placeholder icon (geometric banknote SVG, 512x512) so it no
longer falls back to default.svg in the WebUI.
Central traefik/compose code is now app-agnostic.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
- Add .backup-system-card { margin-top: 20px } — the card stands alone below the
two-column cards row (which has no bottom margin), so it was butting against it.
- Add a server-stack icon to the card header (matches the nebula stroke-icon style).
- DEVELOPMENT.md: document the dashboard "System config" card + its last-backup
status (tag system=config → `system` in the dashboard JSON), the CLI/auto paths,
and that the libreportal app is excluded from the per-app grid.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Make the system config a tracked backup, not just action buttons:
- engine: resticSystemSnapshotsJson (tag system=config) + engineSystemSnapshotsJson
dispatcher — query the system snapshots the way per-app status is queried.
- webui_backup_dashboard.sh: emit a "system": { latest_snapshot, latest_time }
object (latest system snapshot on the primary location), and exclude the
libreportal WebUI app from the per-app grid (it's intentionally not backed up, so
it no longer shows a perpetual "No backup yet" tile).
- backup dashboard card: a status line (dot + "Last backed up <relative>" / "No
backup yet"), populated in renderDashboard from d.system — mirrors the app tiles.
Verified: shell + JS parse; dashboard content assembles to valid JSON with the
system key; engine query defined + dispatched; frontend reads d.system into the
#backup-system-status element.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Add a "System config" card to the backup dashboard with two actions wired through
the task processor (same path as "Backup all apps"):
- "Back up now" -> libreportal backup system
- "Restore…" -> libreportal restore system (confirm dialog explains it lands
in a staging folder and never overwrites live config)
Card copy explains why it matters (the backup-location creds otherwise live only on
the box). Click handlers + runBackupSystem/confirmRestoreSystem added; JS parses,
data-actions match handlers, commands match the CLI subcommands.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Move the whole central scripts/headscale/ tree into containers/headscale/, the
last app-specific dir living centrally:
- 11 sourced function files (incl. the former local/ remote/ subdirs) flattened
into containers/headscale/scripts/ — flat because the container scan is
maxdepth 3, so one subfolder level is the limit; basenames already encode the
local/remote distinction.
- tailscale.sh is a CONTAINER PAYLOAD (ends in a bare `install_tailscale` call,
runs apt/curl) — it must never be sourced into the manager, so it goes to
containers/headscale/resources/ (pruned by the scan), NOT scripts/. Verified
install_tailscale does not leak into the runtime after sourcing.
- Fix tailscaleInstallToContainer to copy the payload from its new resources/
path (it previously referenced ${install_scripts_dir}tailscale.sh, which never
matched the file's actual location) and drop the dead commented docker-cp line.
- Remove the now-moot headscale special-case from generate_arrays.sh; regenerate
(files_headscale.sh drops — headscale is fully container-scanned now).
All 11 functions source + define cleanly; callers resolve by name regardless of
location.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
docker_config_setup_data.sh's "App Specific" if/elif ladder (pihole, nextcloud,
searxng, speedtest, vaultwarden, wireguard, gluetun) becomes a generic hook
dispatch: an app needing computed (non-CFG) compose tags ships
containers/<app>/scripts/<app>_compose_tags.sh defining appSetupComposeTags_<app>
(live-sourced by the container scan, called with the compose path; reads
host_setup/public_ip_v4/CFG_* from scope). Same declare -F pattern as the tool /
update-specifics / webui-refresh hooks.
- 7 per-app hook files added; central ladder replaced by the dispatch.
- The generic gluetun network-mode block stays (any app may route through gluetun);
tagsProcessorGluetunForwardedPorts stays central (hook + network-mode both use it).
- Regenerate arrays (hooks live under containers/, not arrayed).
Verified with stubs: each hook emits exactly the tags the old branch did
(pihole REV_SERVER, nextcloud trusted-domains, gluetun VPN set + forwarded ports,
etc.); apps without a hook are a clean no-op.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Drop the appWebuiRefresh_gluetun -> webuiGenerateGluetunProviders wrapper; rename
the function itself to appWebuiRefresh_gluetun and point the installer + the
gluetun_refresh_providers tool at it. One name, no indirection.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
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>
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>
The SSH Access page was boxed to max-width 860px and centered, unlike the
Overview and System admin pages (.admin-page) which span the full content
width. Drop the cap and match .admin-page padding so /admin/tools/ssh-access
looks like the rest of the Admin area.
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>
Move the loose root-level site/ into a proper containers/weblibreportal app
(mirrors getlibreportal): the Eleventy source + nginx serving ./data via publish.sh
(npm run build -> docroot). Fix gen-data.mjs repoRoot (now ../../.. from
containers/weblibreportal/scripts) so it still finds containers/ for the catalogue.
Decouple the two hosts:
- weblibreportal -> the website (libreportal.org)
- getlibreportal -> downloads only (install.sh + signed release channels); its
publish.sh no longer builds the site, and its config text updated to match.
Both are dev-only project hosting and will move to a separate repo later; for now
they live under containers/ as normal apps. ignores updated for their built
docroots; dropped the dead 'site export-ignore'.
Verified: gen-data builds the catalogue from the new location (33 apps), and
weblibreportal/publish.sh produces a docroot with index.html.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Redo the download/website host as a normal app under containers/ (dogfooded — the
project hosts its own downloads on LibrePortal), instead of the bespoke repo-root
thing. Modeled on speedtest: standard getlibreportal.{config,sh,svg} +
docker-compose.yml (tagged template) so it plugs into the app scan + install
dispatch (installGetlibreportal) like every other app. nginx serves ./data (the
app data dir) — no special /web.
- getlibreportal.config: features category, public (login=false — it's a download
host), no backup (regenerable), healthcheck on.
- docker-compose.yml: nginx:alpine, ./data:ro docroot + ./nginx.conf, traefik tags.
- nginx.conf: install.sh + latest.json no-cache; tarball/.sha256/.minisig immutable.
- publish.sh: assembles the docroot (built site + install.sh + dist/<channel>) into
a target data dir; run on a full repo checkout (site/ + dist/ are host-side).
- exec bits set on the run-directly scripts (make_release.sh, install.sh, publish.sh).
- .gitattributes: dropped the stray 'getlibreportal export-ignore' (the no-slash
pattern would also have excluded containers/getlibreportal — the app must ship);
data/ gitignored.
Verified: app discovered by the site catalog (32 apps), installGetlibreportal matches
the dispatch name, and the full release->publish flow yields a docroot with the
website + install.sh + the signed/checksummed stable channel. The actual app-install
run + DNS/TLS for get.libreportal.org are operational steps (need a real host).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
The admin landing (overview) and the tools pages (ssh-access, system) call
populateSidebar() without first loading window.configData. On a cold admin
visit — e.g. navigating straight from the dashboard — configData is undefined,
so populateSidebar() bails early and the sidebar renders empty. Visiting
Backups happened to set window.configData, which is why returning to admin
afterward showed the sidebar.
Load (cached) config data up front in renderConfig before any branch renders so
the sidebar always has its categories. The config-category path's later
loadConfig is now a cache hit.
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>
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>
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>
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>
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>
New 'System' admin page (sidebar Tools group) rendering the metrics the
collector now produces:
- live ring gauges for CPU, memory, disk and load
- SVG trend charts (CPU/mem/disk/network) with 1h/6h/24h range toggle
- host info + swap + docker summary strips
- per-app table: CPU/mem bars, network, status, CPU sparkline
Charts are hand-rolled SVG in charts.js (LPCharts) — no third-party libs or
CDN calls — themed entirely from the active theme's CSS variables. The
Overview System card now links here.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Reinstall test on Debian 12 surfaced three rootless-only breakages (rooted
was byte-identical/fine):
1. pasta blocked by Debian's passt AppArmor profile (DENIED ptrace read ->
can't open container netns -> rootless dockerd never starts). Default
CFG_ROOTLESS_NET back to slirp4netns (reliable); pasta stays selectable
for hosts that relax the profile.
2. de-sudo mis-assigned helpers by owner. /docker management layer (apps DB
chowned to libreportal by install_sqlite, /docker/logs) is MANAGER-owned,
not dockerinstall. Add runInstallWrite; move apps-DB sqlite3 -> runInstallOp
and /docker/logs appends -> runInstallWrite. Revert ownership-SETUP scripts
(libreportal_folders, app_folder) to runSystem — they must run as root to
establish ownership during install. Container files (/docker/containers/<app>)
stay runFileOp.
3. kernel hardening sysctls written to /etc/sysctl/99-custom.conf, which
'sysctl --system' does not read -> never applied. Write them to
/etc/sysctl.d/99-libreportal-hardening.conf instead.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
FIX: dockerCommandRun rooted path is 'sudo $command' (unquoted word-split),
so 'docker ps --format "{{.Names}}"' was passing the format with LITERAL
quotes -> docker emitted '<name>' and the downstream grep never matched
(broken in rooted too). Switch all docker invocations to runFileOp, which
preserves args via "$@" in both modes (and runs as dockerinstall against
the rootless socket). Fixed monitoring.sh, dashy, tags_processor_network_mode.
Convert: jitsimeet (rm/wget/unzip/mv/sed/tee/gen-passwords on /docker ->
runFileOp/runFileWrite), authelia (config sed/mkdir/chmod/chown/secrets tee
-> runFileOp/runFileWrite; docker exec -> runFileOp docker, preserving
--password), reset_git (cp->/root runSystem, install-dir chown runInstallOp;
kept sudo -u manager). check_update/update_git_check need no change (all
sudo -u manager git, already least-privilege).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
- 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>
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>
Convert the remaining sections off the legacy ?= query form to clean paths,
matching the Admin area:
/apps/<category> (was /apps?=<category>)
/app/<name>?tab=&task= (was /app?=<name>&tab=&task=)
/tasks/<category>?task= (was /tasks?=<category>&task=)
/backup/<tab> (was /backup?=<tab>)
Builders updated everywhere (sidebar, dashboard, notifications, tasks, apps,
app tabs, task-actions, setup watcher); parsers now read the resource from the
path with the legacy ?= kept as a fallback so old links/bookmarks still work
(server already serves index.html at any depth). Route table gains /apps* and
orders it before /app* (since '/apps' startsWith '/app'); active-nav and
config/apps data-loading recognise the new paths.
Tab/task remain ordinary query params (modifiers, not the primary resource).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Replace the Admin area's ?= query URLs with clean, hierarchical paths that
mirror the breadcrumb:
/admin -> Overview
/admin/config/<category>-> Config / <category>
/admin/tools/ssh-access -> Tools / SSH Access
New /admin (+ /admin*) SPA route -> handleAdmin, which parses the path via the
shared window.adminPath / window.adminCategoryFromPath helpers and renders
through the existing ConfigManager. Legacy /config, /config?=<x> and /ssh now
redirect into the matching /admin path, so old links/bookmarks keep working
(server already serves index.html for any depth). Sidebar, Admin Overview,
dashboard link and top-nav now build /admin paths; active-nav + config data
loading recognise /admin across spa.js, topbar.js, router.js, data-loader.js.
Scope: Admin area only — /app, /apps, /tasks, /backup keep their existing ?=
URLs.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
- Sidebar now groups items: Overview at top, a 'Config' heading over the config
categories, and the existing 'Tools' heading over SSH Access.
- Breadcrumb reflects the group: config pages read 'Config' (was 'Admin'), SSH
reads 'Tools', Overview stays 'Admin'.
- SSH Access page restyled to the config page's section layout
(.config-category/.domains-wrapper sections) instead of backup-style cards, so
it matches the other Admin config pages.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Reads files the backup user can't see from the host (container-owned, e.g.
Nextcloud's www-data data dir) by streaming them out THROUGH the container
(docker exec tar) — no host root, no host read perms, works rooted + rootless.
Extracts to staging as plain files so restic keeps full dedup + per-file
restore (not a piped tar blob); the live path is excluded from the snapshot.
Restore streams the staging copy back through a throwaway in-namespace
container that recreates the tree with the app's uid:gid.
Declared via a libreportal.backup.files compose label; Nextcloud (html, 33:33)
is the first to use it. Live capture failure falls back to stop-snapshot-start.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Add an Admin Overview as the Admin landing (default when you open Admin): an
ops/health board distinct from the user Dashboard. Four cards built from data
we already generate — Updates (update_status.json, with one-click update),
Backups (backup dashboard.json), SSH & Security (access.json), System
(disk/memory/system_info) — each with a Manage link into the right section.
Styled like the backup dashboard (tiles/status dots).
Wire-up: 'Overview' is the top sidebar item and the default category
(handleConfig + sidebar), rendered by AdminOverview into #config-section via a
renderConfig('overview') special case. Every Admin page now shows the same
'Admin' breadcrumb header (Overview, SSH Access, and the config categories) for
a consistent Admin → Section feel. User Dashboard gets an 'Admin overview →'
link.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
Rename the Config top-nav to 'Admin' and move SSH Access into its sidebar
under a 'Tools' group, instead of a separate top-level nav item. SSH Access is
rendered by SshPage into the config main pane via a renderConfig('ssh-access')
special case; the sidebar item (config-sidebar.js) routes there. SshPage now
mounts into any container (defaults to #config-section). /ssh redirects to
/config?=ssh-access for old links; the standalone ssh-content.html is removed.
Declutters the top bar and gives system/admin features one home that scales
(updates, users, Connect settings can become sidebar entries later).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>