19 Commits

Author SHA1 Message Date
librelad
a28eed0729 fix(services): route per-service restart through the task system + CLI
The Services tab restart button POSTed to a backend endpoint that (a)
checked the app's compose path from INSIDE the webui container, where
the host's containers root isn't mounted — so every restart failed with
'Compose file not found' — and (b) queued a raw 'docker compose restart'
that the host task processor would run as the manager user, which can't
talk to the rootless daemon anyway. Errors surfaced via a bare alert().

Per-service restart now follows the exact shape of the whole-app verbs:

- CLI: 'libreportal app restart <app> [service]' — the optional service
  arg makes dockerRestartApp restart just that compose service, via
  dockerCommandRun (right user in rootless mode) from the app dir on the
  host, where the compose file actually lives. Service names validated
  against compose-legal characters before touching a shell line.
- WebUI: the button dispatches a 'service_restart' task action through
  the task router (mutations-via-tasks), runs in the background with the
  standard task toast + link — no page switch — and failures use the
  notification system instead of alert(). Because the task runs host-
  side, restarting the WebUI's own libreportal-service now works too.
- Backend: the mutating restart endpoint and its now-unused helpers are
  removed; service-routes.js is read-only surface (status + log tails).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-06-12 23:26:40 +01:00
librelad
b7a0743d8b feat(network): add ipInSubnet + IP-only network reset scope
Foundations for network-drift healing:

- ipInSubnet(ip, cidr): prefix-aware CIDR membership (pure bash), so
  stored IPs can be checked against docker's real subnet. Honours the
  actual prefix, so a healthy /16-subnet + /24-ip-range install is not
  mistaken for drift.

- dockerInstallApp now accepts reset_network="ip": re-roll the static IP
  from the current subnet but PRESERVE published host ports (clears only
  IP rows; LIBREPORTAL_RESET_IP_ONLY keeps port_allocate reusing existing
  ports). This is the heal path — a subnet move strands the IP, not the
  port, so we don't churn bookmarks/forwards/proxy upstreams. reset="true"
  still re-rolls both.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 15:54:55 +01:00
librelad
a27304a191 fix(distribution): harden the artifact apply pipeline (adversarial review)
A 4-lens adversarial security review of the Phase 2 applier raised 19 issues
and confirmed 17 after per-finding verification. All are trust-boundary (they
require the signing key), but several break the explicit "no code-exec, always
reversible, nothing-silent" contract, so all 17 are fixed:

Trust path — fail CLOSED, never misreport:
- lpFetchIndex now surfaces the real signature state (LP_INDEX_SIGSTATE);
  artifactApply REFUSES to mutate unless the index is actually verified, and
  _artifactFetchPayload refuses an unsigned payload. The read path still
  tolerates dev/unsigned but now says "UNSIGNED" instead of "Signed + verified".
- valid_until and index_serial are now MANDATORY + numeric in lpFetchIndex
  (missing = refuse) — closes the anti-withholding / anti-rollback fail-opens.

Injection / code-exec (defense in depth even for a signed payload):
- runFileWrite rootless branch no longer builds a `bash -c` shell string with the
  destination interpolated — it uses the argv form (like runFileOp), so a path
  with a quote can't inject a command as the install user. (shared-helper fix)
- op paths must match a safe-filename charset (no quotes/$/backtick/;/newline);
  set-config-key values and set-compose-image refs are charset-guarded too.
- content_b64 is validated as real base64 at precheck.

Reversibility / honest failure:
- dockerComposeUp now returns the real compose exit status (it always returned 0,
  so the updater's rollback gate AND the apply's start-failure detection were
  fail-open). (shared-helper fix)
- set-config-key undo captures the WHOLE config file (lossless) instead of a
  lossy re-parsed scalar; edit-only (rejects an absent key).
- _artifactReplayUndoFile returns non-zero if any inverse op fails; auto-rollback
  and revert now record "rollback-incomplete"/"revert-incomplete" + isError
  instead of falsely claiming success, and revert keeps the record for retry.
- applied-record write failure is checked — apply rolls back rather than leave an
  un-revertable change. System-scope regen failure is no longer swallowed.
- Writes are path-aware (configs/ -> runInstallWrite, container tree ->
  runFileWrite) so system-scope hotfixes write/restore correctly.
- Checked lazy-sourcing surfaces a clear error instead of a bare exit 127.

Unit-tested 35/35 (adds: command-sub value rejection, bad image-ref, invalid
base64, quote/metachar path-injection rejection, replay-failure reporting).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-31 20:47:18 +01:00
librelad
053a620e22 fix(reliability): split local result=$(cmd) so $? survives for checkSuccess
'local result=$(cmd)' resets $? to 0 (the local builtin's own exit), so the
following checkSuccess always saw success regardless of cmd's real exit — the
mechanism that masked the de-sudo write failures. Split declaration from
assignment ('local result; result=$(cmd)') across all 235 active-code sites
(84 files) so the command's exit reaches checkSuccess. No behaviour change
beyond $? now being accurate (no set -e in runtime code; multi-line
assignments transform safely).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-31 03:09:25 +01:00
librelad
66e747e1ba fix(uninstall): always run name-based container cleanup + drop CLI hint
Two small uninstall-output tweaks.

1. dockerComposeDownRemove now ALWAYS calls dockerRemoveApp (the
   `docker ps -aqf name=…` → stop + rm sweep) as a fallback, even when
   the compose-down step is skipped because the app dir is missing.
   Before, a partial prior uninstall (compose file gone but containers
   still running) produced "App directory not found. Skipping container
   shutdown." and then proceeded as if the uninstall were complete —
   leaving the actual containers running. The name-based sweep also
   runs after a successful compose-down to catch anything compose
   wouldn't pick up (renamed services, orphans from earlier failures).

   While here: the OS_TYPE gate (only Ubuntu/Debian) is gone too —
   `docker compose down` works on any OS with docker, and gating it
   meant Arch/etc. users got NO compose teardown at all.

2. The step-2 header "Keeping Docker images (pass --delete-images to
   remove)" trimmed to just "Keeping Docker images". The `isNotice`
   line below already explains the reuse-on-reinstall behaviour; the
   CLI-flag hint reads as noise in the WebUI task log where users
   can't act on it anyway. CLI users can still pass --delete-images
   (cli_app_commands.sh wires it as before) or tick the WebUI's
   "Also delete docker image" checkbox.

Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-27 15:41:19 +01:00
librelad
cb055b4b1f fix(uninstall): wipe container sub-UID app data via root helper
dockerDeleteData (uninstall) and the wipe-before-restore step in
restoreAppStart both did `runFileOp rm -rf $containers_dir$app_name`,
which runs as $CFG_DOCKER_INSTALL_USER (dockerinstall, uid 1002 on
rootless). That user owns app-template files but CANNOT remove
container sub-UID dirs created by the daemon's userns mapping —
postgres data at uid 232070, nextcloud html at uid 33, etc. The rm
therefore silently failed with

  rm: cannot remove '/libreportal-containers/invidious/postgresdata':
    Permission denied

while still reporting "<app> successfully uninstalled" — leaving the
sub-UID directory tree on disk to confuse the next install and leak
storage.

Fix: route the wipe through a new `app-data-remove` action in the
root-owned libreportal-ownership helper. Root can rm sub-UID files
unconditionally. The helper validates the app name (alphanumeric +
. _ -, no traversal), refuses the WebUI's own slot (libreportal), and
is idempotent when the dir is already gone.

Two callers updated:
- scripts/docker/app/uninstall/delete_data.sh
- scripts/restore/restore_app_start.sh

The helper itself ships root-owned at /usr/local/lib/libreportal/, so a
fresh install or release upgrade is needed to pick up the new action.
Bumped init.sh footprint_version 2 → 3 so the runtime updater
prompts a root re-install on the next release.

Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-27 15:32:44 +01:00
librelad
d941f59388 feat(app): generic installApp driver + dispatcher fallback (Wave A)
The 31 containers/<app>/<app>.sh files each defined install<App>() with
the SAME 10-step sequence — ~4,000 lines of duplicated boilerplate.
Replaces all that with one generic driver + hook surface.

scripts/app/install/app_install.sh:
  installApp <slug> [config_variables]
    — Dispatches on $<slug> (c/u/s/r/i) the same way the per-app .sh
      files did. Same convention; dockerInstallApp's existing
      `declare $app=i` callsite needs no change.
    — Runs the standard sequence: dockerConfigSetupToContainer →
      dockerComposeSetupFile → optional .env copy → fixPermissions →
      dockerComposeUpdateAndStartApp → standard post-install steps
      (appUpdateSpecifics, setupHeadscale, databaseInstallApp,
      webuiContainerSetup, monitoring registration) → final message.
    — Hooks (all declare-f-gated, silent no-op when absent):
        <slug>_install_pre / _post_setup / _post_compose / _post_start
        <slug>_install_message_data   (echoes extra args for menu)
        <slug>_install_post
        <slug>_uninstall_pre / _post
        <slug>_stop_post
        <slug>_restart_post
      Hooks live in containers/<app>/tools/<app>_tools.sh (auto-sourced
      per the modular-per-app-tools convention).

function_install_app.sh:
  When no install<App>() function exists, fall through to
  `installApp <app_name>` instead of erroring. So an app with no .sh
  at all becomes a zero-byte addition — drop in <app>.config +
  docker-compose.yml + <app>.svg, done.

containers/linkding/linkding.sh:
  Deleted (canary). Linkding's body was 100% standard sequence;
  fallback handles it identically. Smoke-tested with stubbed helpers
  — dispatcher fires, generic runs full flow, monitoring integration
  + final-message hook plumbing all intact.

Wave B (next): delete the .sh for every other 'pure-boilerplate' app
(~15 candidates per the survey). Wave C: extract custom logic from
the 7 fat apps into hooks before deleting their .sh.

Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-27 01:43: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
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
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
22364f5421 fix(rootless): enumerate containers/ as its owner, not the manager
Bare `find "$containers_dir"` runs as the manager, but under rootless
containers/ is dockerinstall-owned 751 (traversable, not list-readable by
the manager) -> "find: /docker/containers/: Permission denied". For the
app-log generator that was cosmetic; for dockerComposeUpAllApps /
dockerComposeDownAllApps it silently enumerates nothing so no apps come
up/down. Route these through runFileOp find (dockerinstall in rootless,
manager in rooted — correct in both). The two docker-type switcher finds
are deliberately left: mid-switch the at-rest container owner can differ
from the target-mode user runFileOp resolves to, so they need mode-aware
handling rather than a blind swap.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-24 21:46:17 +01:00
librelad
21afae2eff refactor(desudo): drop runtime root from docker_run, sqlite guards, restores
- docker_run: in rooted mode run docker AS the manager via the docker
  group (no sudo); the type=='sudo' branch was unreachable dead code
- 8 db helpers: fix 'command -v sudo sqlite3' guard to 'command -v
  sqlite3' (bodies already query via runInstallOp)
- restic/kopia single-file dump: write target_file via runBackupOp tee
  (as the backup user, matching the snapshot-restore path) instead of
  root tee
- adguard auth: root-owned scratch via runSystem mktemp

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-24 18:03:36 +01:00
librelad
8b14f26125 refactor(desudo): route scattered runtime sudo through privilege helpers
Convert the remaining ad-hoc 'sudo' calls across the data plane to the
run_privileged helpers so every file op lands as the correct owner with
no blanket root:

- DB/configs (manager-owned): db_list_all_apps, delete_db_file,
  install_sqlite, cli_webui_commands -> runInstallOp
- containers (dockerinstall-owned): scan_container_socket, delete_data,
  webui_task_files, webui_app_log, webui_config_patch,
  application_missing_variables, uninstall_app -> runFileOp/runFileWrite
- genuine root: passwd, tailscale, ufw-docker, sysctl grep, systemd
  unit read, authorized_keys read, nobody chown -> runSystem
- interactive editors and 'id -u': drop sudo entirely (run as caller)
- owncloud/adguard container-UID config edits -> runSystem (funnel;
  docker-exec rework deferred)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-24 18:00:19 +01:00
librelad
3ecf213cab refactor(de-sudo): docker calls via runFileOp/dockerCommandRun, drop sudo
Container-plane docker now routes through the mode-aware helpers instead of
sudo: simple calls (exec/ps/run/build/images/inspect/port/logs across ~15
app/check scripts) -> runFileOp docker (rootless socket as the install user;
rooted via the docker group). The cd && docker compose paths drop the sudo on
the rooted branch (the rootless branch already used dockerCommandRunInstallUser
-- byte-identical now, manager-ready later); gluetun, which had no rootless
branch, now uses dockerCommandRun so force-recreate works in both modes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-24 16:29:22 +01:00
librelad
c6dd2659be refactor(de-sudo): apps DB access via runInstallOp, not sudo
The apps SQLite DB ($docker_dir/$db_file) is owned by the manager user, so
read/write it AS the manager via runInstallOp instead of sudo (root). 48 call
sites across 28 scripts. In rooted this drops root->manager (correct owner);
in rootless it's the manager too (using runFileOp/dockerinstall here was the
'unable to open database' bug). The broken 'command -v sudo sqlite3' check
lines are left untouched (separate pre-existing issue).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-24 16:23:33 +01:00
librelad
7b7e6e06fb fix(compose): always emit a status line when downing an app
dockerComposeDown printed the 'Docker Compose down <app>' header then could
fall through silently: when the effective install type (passed type arg or
CFG_DOCKER_INSTALL_TYPE) was empty/unrecognised no branch ran, and on a
non-Ubuntu/Debian OS the whole block was skipped. Collapse the duplicated
type=='' vs type!='' branches into one mode fallback and add notices for the
unknown-mode and unsupported-OS cases so the header always has a result line.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-24 13:09:21 +01:00
librelad
ee4f7cedbf fix(rootless): run docker ps inside the install-user shell when restarting all containers
dockerStartAllApps expanded $(docker ps -a -q) in the outer control-plane
shell, which has no DOCKER_HOST and so hit the nonexistent rooted socket at
/var/run/docker.sock. In rootless mode that connection fails, the
substitution returns empty, and 'docker restart' is then called with no
arguments. Push the whole pipeline into dockerCommandRun (matching
dockerRestartApp) and guard with xargs -r so it runs against the rootless
socket and no-ops cleanly when there are no containers.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-24 13:02:44 +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
875a60f90f LibrePortal v0.1.0 — initial release
A free, open, self-hosted app platform (GNU AGPLv3): one-click app deploys,
Traefik reverse proxy with automatic SSL, rootless Docker support, gluetun
VPN routing, and a web dashboard to manage it all.

Free & open forever to self-host; optional paid hosted services fund it.
See PROMISE.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-21 20:37:54 +01:00