98 Commits

Author SHA1 Message Date
librelad
dc77ddaa4c feat(linkding): add full per-app tools (5 user-management actions)
Linkding has shipped without any Tools-tab actions since v0.1.0 — the only
artifact was scripts/menu/tools/manage_linkding.sh, a dead legacy CLI menu
referencing an `appLinkdingSetupUser` function that was never defined.
Build the real thing, mirroring bookstack's pattern (manifest + thin tool
wrappers + auth_adapter that drives the app's native admin shell):

  containers/linkding/tools/linkding.tools.json    — manifest, 5 tools
  containers/linkding/tools/linkding_<id>.sh       — one wrapper per tool
  containers/linkding/scripts/linkding_auth.sh     — Django shell driver

Tools (all category=users, so the WebUI's custom user-list panel and its
row-level 🔑 / 👑 / 🗑 buttons light up):

  reset_password   — set_password on an existing user (random if blank)
  create_account   — create_user / create_superuser
  list_users       — emits EZ_USER\t<username>\t<username>\t<role> rows
                     (linkding is username-primary, so username goes into
                     both display slots — keeps the panel click-through
                     identifier consistent with the other tools' fields)
  delete_user      — delete by username (destructive, confirm gated)
  set_admin        — toggle is_superuser + is_staff

Implementation runs entirely inside the linkding-service container via
`runFileOp docker exec ... python manage.py shell -c "<code>"`, reading
inputs through `-e` env vars so quoting stays safe. Django's default
get_user_model() User is used directly — passwords hash exactly the way
the web UI does, admin flags map to the same fields the UI reads.

Also drop the dead legacy stub (scripts/menu/tools/manage_linkding.sh)
and regenerate files_menu.sh so the source-scan no longer pulls it in.
Nothing referenced linkdingToolsMenu — verified by tree-wide grep.

Verified live on dev-ai (Debian 12, linkding installed, Django 5 + sqlite):

  $ libreportal app tool linkding create_account 'username=alice|password=…|admin=true'
  ✓ Linkding user created — Username: alice — Password: …

  $ libreportal app tool linkding list_users ''
  EZ_USER  alice  alice  admin

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-26 20:23:56 +01:00
librelad
1452c31839 fix(admin): SSH Access sidebar icon — inline key SVG, theme-aware
The previous `<img src="/icons/config/security.svg">` icon hardcoded
`stroke="#1e90ff"` (dodger blue) rather than `currentColor`, so on
themes where it should pick up the sidebar foreground colour it just
disappeared or visually clashed. The other Tools / admin sidebar items
(Overview, System, Peers) all use inline SVGs with `stroke=currentColor`
and follow the theme correctly.

Switched SSH Access to an inline key icon in the same style — circle
shackle bottom-left, shaft going up-right to a notched bit. Matches the
'what is this thing' framing: an SSH access page is fundamentally about
managing keys.

security.svg itself is left untouched (might be used elsewhere).

Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-26 20:20:16 +01:00
librelad
cfdd39386c feat(admin): move Peers into Admin/Tools; lift System next to Overview
Two related UI tidies — both removing surface area from the topbar / Tools
group rather than adding new pages.

Peers → /admin/tools/peers
  Was a top-level /peers route with its own topbar nav item, which doubled
  the navigation surface for what's really an admin tool (same shape as
  SSH Access). Now lives under the Admin sidebar's Tools group alongside
  SSH Access. /peers is kept as a legacy redirect → /admin/tools/peers.

  Plumbing:
  - config-sidebar.js gains a Peers entry under the Tools label.
  - config-manager.js gains a 'peers' branch that fetches
    peers-content.html into config-section, then inits PeersPage.
  - window.adminPath() learns 'peers' → /admin/tools/peers.
  - spa.js handlePeers() is now a redirect (mirrors handleSsh).
  - topbar.html drops the Peers nav item.
  - peers-content.html slimmed to a config-section template (no
    standalone page wrapper) so it embeds cleanly under the admin shell.
  - PeersPage gains a rootId constructor arg for symmetry with SshPage
    (queries still work globally — IDs are unique).

System lifted out of the Tools group
  User feedback: 'overview/system are kinda like, the same thing'. Moved
  System to sit right under Overview at the top of the sidebar, before
  the 'Config' label. Both surfaces are admin-landing pages (Overview =
  ops/health summary, System = live host + per-app stats) — distinct from
  config form pages or the Tools utilities.

  config-sidebar.js: System block moved to the top section (right after
  Overview's click handler). Original Tools-group instance removed.

Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-26 20:16:45 +01:00
librelad
82f64eb5c0 feat(migrate): app-specific hooks + peer friendly-name overlay (Phase 4)
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>
2026-05-26 18:00:26 +01:00
librelad
3fe2c0660a feat(peers): direct peer SSH — pairing + peer-shell + pull (Phase 3)
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>
2026-05-26 17:56:57 +01:00
librelad
763092a278 fix(wireguard): move /etc IP-forward edit into libreportal-appcfg
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>
2026-05-26 17:48:43 +01:00
librelad
4430edc40e fix(apps): de-sudo the remaining per-app .sh file ops via runFileOp
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>
2026-05-26 17:48:00 +01:00
librelad
1014dd6e42 feat(peers): introduce 'Peer' as a first-class concept (Phase 2)
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>
2026-05-26 17:43:56 +01:00
librelad
52e4280a67 feat(webui): add 'Migrate' tab — restore an app from another LibrePortal
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>
2026-05-26 17:32:01 +01:00
librelad
c309276f1d feat(backup): also label focalboard (consistent with the same overlap pattern)
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>
2026-05-26 16:16:25 +01:00
librelad
12b4d6823e feat(backup): file-capture labels for linkding, vaultwarden, headscale, mastodon
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>
2026-05-26 16:13:46 +01:00
librelad
d424473b2e feat(backup): auto-discover container-side capture uid:gid (drop the literal)
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>
2026-05-26 15:04:38 +01:00
librelad
af23488df1 tidy: docs + Nextcloud APCu + container-side file-capture rollout
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>
2026-05-26 14:43:28 +01:00
librelad
7e805c2bb0 fix(focalboard): auth tools point at the right sqlite path
_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>
2026-05-26 14:20:13 +01:00
librelad
d30c309d1d feat(nextcloud): switch to fpm-alpine + nginx sidecar + Redis caching wiring
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>
2026-05-26 13:32:08 +01:00
librelad
853b489caa refactor(gluetun): move the network-routing feature into gluetun's folder
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>
2026-05-26 10:43:49 +01:00
librelad
406ebf3bb9 docs(webui): fix stale comment naming (webuiGenerateGluetunProviders -> appWebuiRefresh_gluetun)
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>
2026-05-26 01:25:34 +01:00
librelad
7f797273dd refactor(wireguard): inline the host-conflict guard, drop central allowed_install
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>
2026-05-26 01:21:08 +01:00
librelad
196b8e1dc8 refactor(traefik): per-app middleware hooks + moneyapp placeholder icon
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>
2026-05-26 01:07:14 +01:00
librelad
9f37f7655d polish(webui): spacing + icon for the System config backup card; doc the status
- 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>
2026-05-26 00:43:24 +01:00
librelad
3283b3f7a3 feat(webui): track system-config backup status on the dashboard
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>
2026-05-26 00:38:39 +01:00
librelad
c2c10103b8 feat(webui): surface system-config backup/restore on the backup dashboard
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>
2026-05-26 00:31:23 +01:00
librelad
3e10dc99b4 refactor(headscale): flatten + move headscale into its app folder
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>
2026-05-26 00:00:55 +01:00
librelad
d2595c3ef6 refactor(apps): per-app compose-tag hooks (remove the central App-Specific ladder)
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>
2026-05-25 23:52:53 +01:00
librelad
8670a02c00 refactor(gluetun): collapse to one function name for the refresh hook
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>
2026-05-25 23:48:47 +01:00
librelad
3e6bb565e0 refactor(apps): modularize the gluetun providers generator via a per-app refresh hook
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>
2026-05-25 23:44:42 +01:00
librelad
98e1a0a05d refactor(apps): per-app post-install hooks + move gluetun/crowdsec logic into their apps
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>
2026-05-25 23:38:19 +01:00
librelad
898068a390 refactor(apps): make app tools + helpers fully self-contained per app
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>
2026-05-25 22:45:33 +01:00
librelad
3bc91eef55 refactor(tools): modular per-app tools convention (containers/<app>/tools/) + migrate adguard
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>
2026-05-25 22:30:49 +01:00
librelad
16eda07b3d fix(webui): make SSH Access page full-width like config/admin pages
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>
2026-05-25 22:12:50 +01:00
librelad
ef67ab9b71 refactor(infra): move hosting apps out to LibrePortal-Infra
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>
2026-05-25 21:15:38 +01:00
librelad
ef100acb3c refactor(hosting): website -> containers/weblibreportal; getlibreportal = downloads-only
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>
2026-05-25 20:44:38 +01:00
librelad
63b53c7751 feat(hosting): getlibreportal as a first-class LibrePortal app (phase E)
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>
2026-05-25 20:02:47 +01:00
librelad
3064328aa8 fix(webui): populate admin sidebar on cold visit
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>
2026-05-25 18:13:55 +01:00
librelad
16571134b5 refactor(paths): scrub residual /docker references in display text + comments
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>
2026-05-25 17:18:46 +01:00
librelad
edcdf00aca feat(layout): three-root split + ownership model (phase 2)
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>
2026-05-25 15:21:28 +01:00
librelad
e4872ab511 refactor(paths): single source of truth for a relocatable, split layout (phase 1)
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>
2026-05-25 15:09:39 +01:00
librelad
152d9c5d28 fix(webui): make all icon and data asset URLs absolute under path routing
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>
2026-05-24 23:20:42 +01:00
librelad
447f57ac63 fix(webui): use absolute URLs for topbar partial + nav under path routing
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>
2026-05-24 23:15:46 +01:00
librelad
42f2509193 fix(webui): finish ?=…→/… URL migration in two missed nav spots
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>
2026-05-24 22:29:03 +01:00
librelad
b9ae512d31 auto: session-start commit — 2 file(s) at 2026-05-24 20:20:21 2026-05-24 20:20:21 +01:00
librelad
62f7a84126 feat(webui): Admin System page with gauges, trend charts & per-app stats
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>
2026-05-24 16:47:20 +01:00
librelad
68110d199c fix(rootless): slirp4netns default, manager-vs-container helper split, sysctl path
Reinstall test on Debian 12 surfaced three rootless-only breakages (rooted
was byte-identical/fine):

1. pasta blocked by Debian's passt AppArmor profile (DENIED ptrace read ->
   can't open container netns -> rootless dockerd never starts). Default
   CFG_ROOTLESS_NET back to slirp4netns (reliable); pasta stays selectable
   for hosts that relax the profile.
2. de-sudo mis-assigned helpers by owner. /docker management layer (apps DB
   chowned to libreportal by install_sqlite, /docker/logs) is MANAGER-owned,
   not dockerinstall. Add runInstallWrite; move apps-DB sqlite3 -> runInstallOp
   and /docker/logs appends -> runInstallWrite. Revert ownership-SETUP scripts
   (libreportal_folders, app_folder) to runSystem — they must run as root to
   establish ownership during install. Container files (/docker/containers/<app>)
   stay runFileOp.
3. kernel hardening sysctls written to /etc/sysctl/99-custom.conf, which
   'sysctl --system' does not read -> never applied. Write them to
   /etc/sysctl.d/99-libreportal-hardening.conf instead.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-24 00:12:06 +01:00
librelad
f1ce5e3822 harden(desudo): fix docker-cmd helper bug; convert jitsi/authelia/reset_git
FIX: dockerCommandRun rooted path is 'sudo $command' (unquoted word-split),
so 'docker ps --format "{{.Names}}"' was passing the format with LITERAL
quotes -> docker emitted '<name>' and the downstream grep never matched
(broken in rooted too). Switch all docker invocations to runFileOp, which
preserves args via "$@" in both modes (and runs as dockerinstall against
the rootless socket). Fixed monitoring.sh, dashy, tags_processor_network_mode.

Convert: jitsimeet (rm/wget/unzip/mv/sed/tee/gen-passwords on /docker ->
runFileOp/runFileWrite), authelia (config sed/mkdir/chmod/chown/secrets tee
-> runFileOp/runFileWrite; docker exec -> runFileOp docker, preserving
--password), reset_git (cp->/root runSystem, install-dir chown runInstallOp;
kept sudo -u manager). check_update/update_git_check need no change (all
sudo -u manager git, already least-privilege).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-23 23:51:01 +01:00
librelad
0c719b5912 harden(desudo): add runInstallOp helper + convert adguard/traefik/crowdsec/dashy
- New runInstallOp helper for manager install-dir/template ops (rooted:
  sudo; rootless: run as the current manager user, which owns the tree).
- adguard.sh, traefik.sh: container-config sed -> runFileOp.
- crowdsec.sh: host crowdsec systemctl/apt-get -> runSystem.
- dashy_update_conf.sh: conf-file mkdir/chown/md5sum/tee -> runFileOp/
  runFileWrite; docker ps/restart -> dockerCommandRun.
Deferred (cross-owner copy / temp-file across /tmp<->/docker, need rootless
env to bridge correctly): owncloud_setup_config.sh, adguard_auth.sh.

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-23 22:52:44 +01:00
librelad
a103aa6864 refactor(webui): path-based URLs for apps, app, tasks, backup
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>
2026-05-23 19:03:54 +01:00
librelad
fab6997cd7 refactor(webui): path-based Admin routing (/admin/config/<x>, /admin/tools/ssh-access)
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>
2026-05-23 18:36:06 +01:00
librelad
23a15345fb refactor(admin): sidebar Config/Tools groups, per-group breadcrumbs, SSH matches config layout
- 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>
2026-05-23 18:19:25 +01:00