256 Commits

Author SHA1 Message Date
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
bdd73b4686 harden(desudo): append-capable runFileWrite + convert config-to-container
Add -a/--append to runFileWrite so the pervasive /docker/logs log-append
idiom (`… | sudo tee -a $logs_dir/$docker_log_file`) routes through the
mode-aware helper instead of raw sudo.

Convert scripts/config/docker/docker_config_to_container.sh fully: all
ops target /docker app config + logs (data-plane), so md5sum/grep/chmod/
cmp/editor -> runFileOp and the log-appends -> runFileWrite -a.
Byte-identical in rooted.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-23 23:26:13 +01:00
librelad
82839abea6 harden(desudo): arg-safe runFileOp + convert DNS subsystem off raw sudo
Give dockerCommandRunInstallUser an --argv mode that execs arguments
verbatim (sudo -u <user> env ... "$@") instead of bash -c "$*", and
point runFileOp at it. The old $*+bash -c re-parse silently mangled
backslashes/quotes in args — e.g. sed scripts (\1, \( become 1, ( ) and
the sqlite3 .backup arg — so rootless data-plane ops with regex were
broken. Verified: the WG_DEFAULT_DNS sed now applies correctly as the
install user. All existing runFileOp callers pass plain commands, so the
switch is safe (and fixes the latent sqlite3 case).

Convert scripts/network/dns/setup_dns.sh: /etc/resolv.conf edits and
ping -> runSystem; the WG_DEFAULT_DNS compose-file sed -> runFileOp.
Byte-identical in rooted; correct in rootless.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-23 23:22:46 +01:00
librelad
0bf9c41c51 harden(rootless): offset userns surface with kptr/ptrace/bpf-jit sysctls
Enabling unprivileged user namespaces for rootless widens the kernel
attack surface reachable by unprivileged users (a known source of LPE
CVEs). Pair it with three distro-portable, low-impact sysctls that close
the surfaces those exploit chains rely on: kernel.kptr_restrict=2 (hide
kernel pointers), kernel.yama.ptrace_scope=1 (block cross-process
ptrace), net.core.bpf_jit_harden=2 (harden the JIT). Added as a separate
guarded LIBREPORTAL KERNEL HARDENING block so it's clearly deliberate and
independently idempotent.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-23 22:59:18 +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
f6a51f9a19 fix(rootless): run WebUI container as userns-root with socket gid 0
In rootless the container was set to user: <host-uid> (e.g. 1002:1002) with
group_add: <host-sub-gid> (e.g. 166528). Inside the daemon's user namespace
those are out of range — group_add made runc call setgroups() with an unmapped
GID ('setgroups: invalid argument'), so the WebUI container never started.
In rootless the container now runs as 0:0 (userns-root == the install user, which
owns the bind-mounts and the rootless socket) with socket gid 0. Rooted is
unchanged. Verified: libreportal-service comes up and talks to the rootless
socket.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-23 22:24:43 +01:00
librelad
049d5de6a8 fix(rootless): start daemon with slirp4netns, not invalid pasta+builtin
The rootless dockerd override forced NET=pasta + PORT_DRIVER=builtin, which
rootlesskit rejects ('pasta requires port driver none or implicit'), so the
daemon failed to start every time (the real cause behind 'rootless socket not
found'). Use slirp4netns + builtin (valid, still skips the userspace
port-handler). Verified: daemon now comes up, docker Server 29.5.2 responds.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-23 22:02:42 +01:00
librelad
49c1a23221 fix(rootless): run install-user commands via sudo -u, not SSH
dockerCommandRunInstallUser ssh'd to <user>@localhost, but nothing set up an
SSH server/keys/authorized_keys, so every rootless setup command (daemon
install, systemctl --user) silently no-op'd. Replace with 'sudo -u <user> env
…' that sets XDG_RUNTIME_DIR / DBUS_SESSION_BUS_ADDRESS / DOCKER_HOST / PATH
explicitly; linger keeps the user systemd + /run/user/<uid> alive so
systemctl --user works. No SSH server, no keys, less attack surface, and
sudo -u to an unprivileged user is not a root escalation.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-23 21:42:29 +01:00
librelad
90584f0b30 fix(rootless): actually create the docker install user
useradd was missing its login-name argument (and -m), so it failed — silently,
because local result=$(...) swallowed the exit code and checkSuccess reported
success. The rootless install user was therefore never created, which cascaded
into 'invalid user dockerinstall' and a daemon that never came up. Pass the
username + -m (subordinate uid/gid ranges come from login.defs), unmasked.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-23 21:23:12 +01:00
librelad
d5acb7e169 refactor(backup): route /docker ops through mode-aware helper
Convert the backup/restore data-plane sudo calls (mkdir/chown/rm/sqlite3/tar/
gzip|tee) to runFileOp/runFileWrite. Rooted behaviour is identical (helper runs
sudo); rootless will run them as the unprivileged install user. Pilot subsystem
for the wider de-sudo. verify.sh's /tmp scratch ops left as-is (different
ownership domain, handled separately).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-23 20:45:28 +01:00
librelad
5c928fe9c0 feat(privilege): mode-aware privileged-op helper
Single place that decides how a privileged op runs by Docker mode:
- runFileOp / runFileWrite: /docker data-plane ops — rooted uses sudo (identical
  to today), rootless runs as the unprivileged install user (no root).
- runSystem: genuine system-admin ops, sudo in both modes, funnelled here so it
  can later be confined to a scoped sudoers allowlist.
Call sites converted to these are byte-for-byte unchanged under rooted, so
existing/live boxes can't regress; rootless gets the de-privileged path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-23 20:35:18 +01:00
librelad
9104c1770e chore: regen source arrays to include files_ssh.sh
The SSH-access feature's files_ssh.sh array was never registered in
files_source.sh, leaving it unsourced and blocking the deploy auto-merge.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-23 20:31:52 +01:00
librelad
036f72d3c2 fix(backup): verify against snapshot restorability, not the live dir
With live dumps + container-side file captures the live app dir intentionally
differs from the snapshot (raw DB dirs and private trees are excluded, replaced
by dumps/captures), so the old source-vs-restored file-count check false-failed.
The scratch restore succeeding already proves restorability (restic hash-checks
every blob); keep a non-empty sanity check instead.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-23 18:27:28 +01:00
librelad
3a1cd8464e fix(backup): make captured file staging readable by the backup user
The container capture preserved the app's ownership (e.g. www-data 0640), so
restic still hit permission denied on the staging copy. chown the staging tree
to the backup user after capture (modes unchanged, so the owner reads fine);
real ownership is reapplied from the descriptor on restore.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-23 18:23:03 +01:00
librelad
94c9e83c42 feat(backup): container-side capture of private app files
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>
2026-05-23 18:15:53 +01:00
librelad
d0ec43e3ca fix(focalboard): chown data dir to runtime uid so db persists
Focalboard runs as nobody (65534) but fixPermissionsBeforeStart hands the app
dir to the install user, so the server couldn't open its sqlite db on the newly
mounted data dir. Chown data/ to 65534 in appUpdateSpecifics and restart.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-23 17:18:00 +01:00
librelad
1e6eb628ce fix(backup): survive DB engine first-init restart on restore
Live-restore of a server DB (MariaDB/Postgres) raced the engine's first-init:
it starts a throwaway temp server, runs setup, then restarts the real one. The
old ping-based readiness passed against the temp server and the load hit the
restart, failing once.

- _backupDbWaitReady now requires a real query to succeed on two consecutive
  checks, so the restart breaks the streak and we only proceed once the real
  server is stably up.
- The dump load is retried (idempotent — the dump drops+recreates each object)
  to ride past a final init bounce.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-23 16:55:58 +01:00
librelad
3a679d7343 feat(ssh): admin host SSH-access engine (backend + CLI + snapshot)
Fresh, on-demand inbound SSH-access management for the host (replaces the old
maze). scripts/ssh/host_access.sh manages the install user's authorized_keys —
add a pasted public key (validated), list, remove — and toggles sshd password
login behind a lockout guard (won't disable passwords with no key; won't drop
the last key while passwords are off; sshd -t before reload, with backup).

New 'ssh' CLI category (status/key-add/key-remove/password-auth/generate) and
a webuiGenerateSshAccess snapshot (data/ssh/access.json: user, password_auth,
authorized keys as type+fingerprint+comment — public only) wired into the
regen chain. Nothing runs automatically; only explicit admin actions change
anything. WebUI page next.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-23 16:40:59 +01:00
librelad
7b32dc2e29 fix(backup): clean snapshot-id capture + accept --latest on restore
Found while testing live backups end-to-end:

- Engine backup adapters logged to stdout, so the caller's $() snapshot-id
  capture was polluted with log text — verify-after-backup then failed with
  'no matching ID' on every run. Route their log lines to stderr so stdout is
  only the id (restic/borg/kopia).
- 'libreportal app restore <app> --latest' (as the help advertises) and the
  bare 'restore <app>' both failed: --latest was passed to restic verbatim and
  unset args arrive as the literal 'empty'. Normalise both to 'latest'.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-23 16:39:56 +01:00
librelad
2873a92b80 refactor(ssh): remove the vestigial host-SSH key system
The old inbound-admin-SSH layer was effectively dead: gated on config flags
that don't exist (CFG_SSHKEY_*_ENABLED, CFG_REQUIREMENT_SSHREMOTE), its
authorized_keys installer was unwired, and its download path (sshdownload
container) was already retired. What remained reachable was either a no-op or
a lockout footgun (disable-passwords with no working key install).

Remove it whole: scripts/ssh/*, the four SSH requirement checks, the SSH tools
menu, the dead webui SSH populater, and the unused ssh DB inserts; drop their
calls from the start/requirements/menu flows. A fresh, WebUI-driven admin SSH
access feature replaces it next.

Also make generate_arrays.sh self-healing: prune files_*.sh whose source
folder no longer exists (cleared the now-stale files_ssh.sh + an orphan
files_api.sh) so removed areas don't linger in the sourced set.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-23 16:31:10 +01:00
librelad
d3faa2514f feat(backup): SSH key card in the sftp location editor
When a location uses SSH key auth, show a key card: paste an existing private
key, or 'Generate keypair', then the card displays the public key to copy into
the remote server's authorized_keys (with Copy/Delete). Wires to the
ssh-key-set/generate/delete CLI; key mutations refresh locations.json so the
card reflects state immediately. applySshAuthVisibility toggles the card vs the
password field by auth mode. Private key only ever flows in (base64); only the
public key is ever shown.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-23 16:17:34 +01:00
librelad
19c76f0a3f feat(backup): CLI + data plumbing for per-location SSH keys
Expose the existing location_ssh.sh key store through the backup CLI:
'backup location ssh-key-set|ssh-key-generate|ssh-key-public|ssh-key-delete <idx>'
(the WebUI runs these as tasks). The locations generator now emits
ssh_key_exists + ssh_public_key (public key only — the private key never
leaves the per-location ssh.key file), so the editor can show the key state.
Also fix the stale SSH_AUTH label (~/.ssh/id_rsa -> managed per-location key).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-23 16:11:31 +01:00
librelad
3ba3f77f0b feat(backup): expose per-app strategy override on all apps, context-aware
Every backup-scope app now carries CFG_<APP>_BACKUP_STRATEGY=auto, so the
Backup Strategy dropdown appears in each app's Advanced tab — not just the
DB apps.

To keep it honest, the 'live' option is hidden where it isn't safe:
- apps.json generator emits backup_live_capable per app (from compose backup
  labels: a dumpable DB, or a live-safe marker).
- apps-manager filters the live option out of the strategy select when the
  current app isn't live-capable, so apps like gitea/focalboard (a DB we don't
  yet dump) never offer it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-23 15:41:55 +01:00
librelad
27ad517626 feat(backup): per-app strategy override (advanced, context-aware)
Adds CFG_<APP>_BACKUP_STRATEGY (default auto) so an app's backup strategy can
be overridden from its Advanced config tab, taking precedence over the global
default. Added to the 10 live-capable apps, so the dropdown's 'live' option only
appears where it actually works.

- backupResolveStrategy now checks the per-app override before the global value.
- backupAppLiveCapable / backupAppStrategyOptions expose capability + the valid
  option set; predicate helpers hardened with explicit returns so they behave
  identically with or without shell errexit.
- BACKUP_STRATEGY field mapping (select, advanced) renders the dropdown.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-23 15:34:17 +01:00
librelad
c14cc8f4f2 fix(webui): register webui_backup_schema.sh in the source arrays
New script files are sourced from the committed files_*.sh arrays (built by
generate_arrays.sh), not a live tree scan — and quick deploys don't rerun
generate_arrays. So the schema generator added last commit was never loaded
live: webuiGenerateBackupSchema was undefined, breaking the webui_updater
backup chain at that step (skipping the passwords regen after it) and leaving
schema.json un-generated.

Regenerate the arrays so the file is registered; deploy now sources it and
'webui generate all' rebuilds schema.json on its own.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-23 15:31:57 +01:00
librelad
d6e7df8ada refactor(backup): move location field schema to a generated JSON
The per-type field map lived hardcoded in backup-page.js. Add a
webuiGenerateBackupSchema generator that emits the type -> ordered field list
to data/backup/generated/schema.json (wired into the backup regen chain and
the CLI 'webui generate backup'). The editor fetches it into this.locSchema
and reads it via locFieldsForType; BACKUP_LOC_FIELDS_BY_TYPE stays only as a
fallback if the fetch fails.

Keeps the data-in-generators pattern consistent — the schema now has one
backend source of truth. The dynamic show/hide behaviors (SSH auth, path
mode, engine filtering) remain frontend logic by nature.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-23 15:22:53 +01:00
librelad
69f7289b4a feat(backup): declare server databases + fail safe to stop on dump failure
- Add libreportal.backup.db labels to the MariaDB/Postgres apps (nextcloud,
  owncloud, bookstack, mastodon, invidious) so they back up live + consistent.
- If a declared dump cannot be taken (DB down, wrong path), the backup falls
  back to stop-snapshot-start for that run instead of snapshotting torn data —
  a misconfiguration degrades to 'safe with downtime', never to 'unsafe'.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-23 15:12:55 +01:00
librelad
d9f2feef05 feat(backup): consistent live database backups with auto strategy
Adds a logical-dump path so apps with a database can be backed up with zero
downtime and full consistency, instead of stopping the container.

- backup_db.sh: dump each declared DB live (mysqldump --single-transaction /
  pg_dump / sqlite3 .backup), exclude the raw data dir from the snapshot, and
  replay the dump on restore (pre-start rehydrate for sqlite, post-start load
  for server engines).
- Databases are declared via a 'libreportal.backup.db' compose label so the
  metadata travels with the app in the snapshot.
- New 'auto' strategy (now the default): live where a DB is dumpable or the app
  is marked live-safe, stop-snapshot-start otherwise. Explicit stop/pause/live
  remain as overrides.
- restic/borg/kopia adapters honour an exclude list on the live path.
- Manifest records the resolved per-app strategy and dumped databases.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-23 15:09:14 +01:00
librelad
d682178a08 feat(backup): configurable Default Backup Location; simplify Path Mode label
Automatic path mode hardcoded /docker/backups/<id>, baked into the Path Mode
dropdown label. Add a CFG_BACKUP_DEFAULT_PATH option in the Backup Engine
config ("Default Backup Location", default /docker/backups) and have
backupLocationResolvedPath build the auto path from it (<base>/<id>, trailing
slash tolerated). Defaults to the old path, so existing auto locations are
unchanged.

Path Mode's option is now just "Automatic" (no inline path); its tooltip
points at the Default Backup Location config option instead.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-23 14:51:43 +01:00
librelad
24abe412e0 feat(backup): move Engine into the location editor's Advanced tab
The backup engine is an implementation detail — LibrePortal picks a sensible
default and handles it — so it doesn't belong next to Name/Type on the
Connection tab. Add ENGINE to LOC_ADVANCED_SUFFIXES and mark it **ADVANCED**
in the location.config template + seed so it's metadata-driven.

Since the engine select now lives in the Advanced tab while SSH-auth and
path-mode stay on Connection, refreshInlineTypeFields re-applies the dynamic
behaviors (engine filtering, SSH/path visibility) against the shared
.task-details scope rather than a single panel.

Also fixed the live per-location engine label (restic -> Restic) which now
surfaces in the dropdown via the generator-emitted options.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-23 14:39:48 +01:00
librelad
3684ccaf68 feat(config): emit per-location field metadata into configs.json
The config generator only scanned flat per-category files, so the dynamic
CFG_BACKUP_LOC_N_* keys carried no titles/descriptions/options — the Locations
editor had to hardcode that metadata in backup-page.js. Add a pass that
descends into configs/backup/locations/<n>/location.config and emits each key
(value/title/description/options) into the config map, plus an "advanced"
flag parsed from a **ADVANCED** token in the field comment (stripped from the
user-facing description).

These keys use subcategory "backup_locations", which isn't in any category's
subcategory_order, so the generic /config page ignores them — only the custom
Locations editor consumes them. URI, SSH port, and append-only are marked
advanced. Verified: configs.json stays valid JSON and /config subcategories
are unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-23 13:38:03 +01:00
librelad
4e0b057277 feat(backup): capitalize Restic and surface the default engine in location dropdowns
- Display the restic engine as "Restic" to match BorgBackup/Kopia. The
  lowercase name lived in scripts/backup/engines/restic.json (drives the
  location-row engine pill, per-location engine select, and engine modal),
  the hardcoded per-location dropdown options, the engine-list fallback, and
  the config-option metadata. All set to "Restic".
- In each location's Engine dropdown, float the system-default engine
  (CFG_BACKUP_ENGINE) to the top and tag it "(default)", mirroring the
  retention-preset pattern.

Repo config metadata is the install template (add-only reconciliation), so
the live /docker/configs/backup/backup_engine label was updated in place too
for the global Configuration-tab dropdown on this install.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-23 13:16:33 +01:00
librelad
afa88c8646 fix(crontab): clear user crontab as root so stale-owned spool files heal
On installs migrated from EasyDocker the spool file
/var/spool/cron/crontabs/<user> can be left owned by a defunct UID. The
sticky bit on the spool directory then blocks the current install user from
replacing it, so every `crontab -` write failed with
"rename: Operation not permitted" while the scripts still printed success.

crontabClear now removes the crontab as root (`crontab -u <user> -r`), which
bypasses the sticky bit and clears the stale file; the setup steps recreate
it owned by the install user, so the next crontab refresh self-heals.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-23 12:48:40 +01:00
librelad
315c528306 refactor(webui): silence per-file touch/chown noise in data generators
The WebUI data snapshots (locations.json, dashboard.json, snapshots_*.json,
etc.) are regenerated on every wizard/config change. Each file emitted two
extra success lines via createTouch — "Touching <file>" and "Updating
<file> with <user> ownership" — which spammed the output around the genuinely
useful "... JSON regenerated" line.

Add an optional "silent" flag to createTouch (third arg; default keeps the
existing loud behaviour for interactive install flows) and pass it from every
WebUI data generator/task. Touch + chown still run; only the logging is
suppressed for these background regenerations.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-23 12:40:32 +01:00
librelad
179b895cac fix(backup): resolve docker_install_user for every CLI command
WebUI-driven commands (`setup finalize`, `backup`, restore) ran with an
empty $docker_install_user because cliInitialize only called
checkInstallTypeRequirement for the `app` category. The backup engine then
ran `sudo -E -u "" restic init`, which sudo rejects with a usage dump —
surfacing as "Failed to initialize Local disk" in the setup wizard.

Factor the user resolution out of checkInstallTypeRequirement into a
side-effect-free resolveDockerInstallUser (rooted -> sudo_user_name,
rootless -> CFG_DOCKER_INSTALL_USER, with fallbacks so it is never empty)
and call it at the cliInitialize chokepoint so all command categories get a
valid install user, not just app.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-23 12:33:19 +01:00
librelad
4ce0340ef8 refactor(backup): replace per-app cron stagger with task-queue scheduler
Application backups were driven by one crontab entry per app, each offset by
id * CFG_BACKUP_CRONTAB_APP_INTERVAL minutes. That minute offset is written
straight into cron's 0-59 minute field, so past ~20 apps it overflowed into
an invalid entry that silently never fired, and the fixed spacing could not
serialize backups that ran longer than the gap.

Replace it with a single daily entry (`libreportal backup scheduled`) that
enqueues a backup task per enabled app. The existing systemd task processor
drains them serially — no minute overflow, real serialization, and backups
are now visible/cancellable in the Tasks UI. Per-app enable is read from
CFG_<APP>_BACKUP at schedule time instead of being mirrored into crontab.

Removes the stagger machinery (timing/setup/check/remove scripts), the
now-unused cron_jobs table + insert, and the CFG_BACKUP_CRONTAB_APP_INTERVAL
config knob and its WebUI field.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-22 14:34:35 +01:00
librelad
300301e6aa style(cli): carry glyph markers through the install scripts
Propagate the ✓ Success / ✗ Error / ! Notice / ❯ Question glyphs (from markers.sh) through the rest of the pipeline: swap the inlined helpers in init.sh and generate_arrays.sh, and replace raw echo -e "${RED}ERROR:${NC}" calls with the isX helpers in config_check_missing.sh, check_success.sh, initilize_files.sh, and reset_git.sh.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-22 13:25:59 +01:00
librelad
5ce0a88de0 style(cli): rebrand message markers with glyphs + portal chevron
Replace the ALLCAPS "SUCCESS:/NOTICE:/ERROR:/QUESTION:/OPTION:" prefixes
with distinct per-status glyphs and calmer title-case words:
  ✓ Success   ! Notice   ✗ Error   ❯ Question   ❯ Option
The portal chevron ❯ marks the interactive prompts. Distinct glyph + word
stays readable with no colour and greppable in logs. Display-only; nothing
parses these prefixes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-22 11:56:16 +01:00
librelad
f7240cd096 style(cli): box section headers and the logo in double-line borders
Swap the ### hash headers (isHeader) for a ╔═╗ ║ ╚═╝ double-line box and
wrap the LibrePortal logo in a matching 52-wide box. Build the rule with
printf-repeat and fixed pad widths instead of tr/${#} so multibyte box
chars stay aligned regardless of locale. Mirrors the credentials panel.

Applied to all three copies (markers.sh, init.sh, generate_arrays.sh).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-22 11:50:45 +01:00
librelad
d3681163af feat(config): regenerate config files from template (batch add + delete)
Replaces the slow, interactive per-variable scan with a deterministic
reconcile: each live config is rebuilt from its (freshly-cloned) template —
keeping the user's existing values, adding new template keys
(CFG_REQUIREMENT_CONFIGS_AUTO_UPDATE), and dropping keys the template no
longer defines (new CFG_REQUIREMENT_CONFIGS_AUTO_DELETE, default true).
Structure/order/comments follow the template; non-interactive; atomic with a
.bak; refuses to act on a missing/empty template so a broken clone can't wipe
a config. Applies to both general and per-app configs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-22 11:38:22 +01:00
librelad
d0b7b1a32f style: tidy comments — drop historical/removed-X notes
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-22 11:26:42 +01:00
librelad
2e4f4202e1 refactor(routing): retire HOST_NAME — derive primary host from per-port subdomains
The static per-app CFG_<APP>_HOST_NAME is gone. host_setup (the app's
canonical FQDN, feeding the legacy single DOMAINSUBNAME_DATA used by app env
vars, the app URL and trusted-domains) is now derived from the app's primary
Traefik port's subdomain: first recommended port, else first Traefik port;
@/root -> apex, set -> sub.domain, empty -> app-name. Removes HOST_NAME from
all app configs, the config-form field mapping (Hostname), the dead
headscale stub, and wireguard.sh (now uses host_setup). Completes the move to
dynamic per-port subdomain routing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-22 11:25:00 +01:00
librelad
36e0d31385 Merge branch 'claude/2' into main
- Data-driven Eleventy marketing site (site/)
- HOST_NAME honoured for subdomains + @ apex hosting
- Dynamic per-port subdomains with router-block toggle (all apps converted)
- Split-horizon local DNS (AdGuard wildcard + Pi-hole hosts)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-22 10:59:48 +01:00
librelad
e5f6f4c371 feat(dns): split-horizon local DNS for app subdomains
setupLocalDnsRewrites points every configured domain at the server's LAN IP
inside the self-hosted resolver, so app subdomains resolve locally and hit
Traefik directly (valid certs, no router hairpin). AdGuard gets a wildcard
rewrite per domain via its REST API; Pi-hole gets per-host A records in the
supported, mounted custom.list (no wildcard support there). Safe by
construction: idempotent, guarded by installed-checks, cannot corrupt the
resolver. Hooked into the Apply-DNS actions and resolver install. Also drops
the dead HOST_NAME read from the setupDNSIP stub.

NOTE: needs a live smoke-test — the AdGuard API call and Pi-hole reload
can't be exercised without the running containers.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-22 01:10:56 +01:00
librelad
149fce835e fix(routing): per-port subdomain falls back to app-name when unset
An empty subdomain previously resolved to the domain apex, which would
collide on the root for any unconfigured Traefik port. Treat empty as the
app-name default (matching legacy behaviour); apex is reachable only via the
explicit @ / root sentinel.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-22 01:10:56 +01:00
librelad
dec3055b63 feat(routing): dynamic per-port subdomains + router-block toggle
Replace the static one-host-per-app model with per-port routers: each
Traefik-managed port carries a subdomain (12-col PORT format) and gets a
DOMAINSUBNAME_TAG_<n> host, so one container can serve unlimited hosts.
tagsProcessorPortSubdomains stamps per-port hosts (subdomain @/empty = apex,
multi-level allowed); tagsProcessorPortRouterBlocks comments out
# TRAEFIK_PORT_<n>_BEGIN/END blocks for non-Traefik ports so unfilled
placeholders never ship (mirrors GLUETUN_OFF). Convert all 27 router apps
(subdomains seeded from HOST_NAME; headscale admin. prefix -> subdomain).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-22 00:45:01 +01:00
librelad
5d47a6bad5 fix(routing): honour HOST_NAME for app subdomain; add @ apex hosting
HOST_NAME was read but ignored — the FQDN was built from app_name, so 8
apps (vault, cloud, search, notes, social, meet, board, bookmark) routed at
the wrong host and Traefik disagreed with DNS. Build host_setup from
HOST_NAME (falling back to app_name); treat HOST_NAME="@"/"root" as the
domain apex (root-of-domain hosting, previously impossible). Document @ in
the Hostname field tooltip.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-22 00:45:01 +01:00
librelad
79a1ec4cc3 fix(install): resolve installer function name case-insensitively
dockerInstallApp built the installer name by upper-casing only the first
letter of the slug (libreportal -> installLibreportal), which can't match
camelCase installers like installLibrePortal. After the EasyDocker ->
LibrePortal rename this broke `libreportal` installs with
"installLibreportal: command not found".

If the naive name isn't a defined function, resolve it case-insensitively
against the function table (compgen -A function), and fail with a clear
message if nothing matches. Works for any compound brand/app name.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-22 00:34:26 +01:00
librelad
7ec1e33b56 style(branding): drop the divider line under the logo
Keep just the wordmark + portal; the underline read poorly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-22 00:34:14 +01:00
librelad
0c7eac89fc style(branding): revert divider to low marks, keep top blank line
The raised (‾▔) divider read strangely; go back to the low _▁ step-ticks
the prior look used and restore the leading blank line. Keep the divider
extended to the end of the final letter.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-22 00:26:53 +01:00