LibrePortal/scripts/peer/peer_shell.sh
librelad 8b5e02c760 refactor(storage): resolve every app directory through appDir
The main sweep — ~260 call sites across ~100 files move from string
concatenation on a single root to appDir/storageAppDirs/storageAppConfigs.
On a single-root install the resolved paths are identical, so this is a
no-op until a location is registered.

Enumerators were the interesting half. `for d in "$containers_dir"/*/`
appears in the menus, the registry/artifact scanners and the DNS setup —
and a shell glob cannot list a rootless 751 tree at all, which is the
same bug config_find_file.sh already documents in a comment. Routing them
through storageAppDirs (which enumerates as the owning user) fixes that
alongside the multi-root work.

Three places needed judgement rather than substitution:

db_app_scan.sh deletes database rows and port allocations for apps whose
folder is missing, and reaps "empty" app dirs. With a storage location
unmounted, every app on it looks exactly like that. Each of those
branches now gates on appStorageAvailable first — an app on an unplugged
drive is skipped with a notice, never deleted.

instance_create.sh rewrites cloned hooks so an instance touches its own
directory instead of the base app's. Its sed matched ${containers_dir}<type>,
which this sweep just replaced with $(appDir <type>) — so it would have
silently stopped redirecting, and an instance would have written to the
original's files (the adguard auth adapter case its own comment warns
about). Now matches both appDir forms, verified against bare, quoted,
unrelated-app, legacy and prose cases.

peer_shell/peer_pull streamed and extracted relative to the primary root.
Both now use the app's own root, and peer_shell keeps a single-root
fallback since it runs as a restricted SSH shell with no LibrePortal env.

Also fixes a pre-existing bug found on the way: webui_app_config.sh
tested "$containers_dir/frontend/data/last_update", one level short of the
real tree under the libreportal app dir, so the WebUI refresh trigger
after a config update has never once fired.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 04:09:51 +01:00

127 lines
4.5 KiB
Bash

#!/bin/bash
# peer-shell — the forced-command dispatcher invoked by an incoming SSH peer.
#
# Deployed by peerInstallShell() to ~<manager>/.local/bin/peer-shell, then
# every incoming peer key in authorized_keys has:
#
# command="~/.local/bin/peer-shell <peer-name>",no-pty,no-port-forwarding,...
#
# sshd sets $SSH_ORIGINAL_COMMAND to whatever the caller asked for. We parse
# it, whitelist the verb, and refuse anything else. The peer name passed as
# $1 (by the forced-command itself) lets us scope and audit calls per peer.
#
# Trust boundary: this script runs as the manager user, the same identity the
# rest of LibrePortal runs as on a rootless install. It deliberately has no
# write paths into root-owned territory — everything it can touch is what
# the manager could already touch through the WebUI.
set -u
LP_PEER_NAME="${1:-unknown}"
# Audit log — appended; rotated by logrotate if present, otherwise harmless.
LP_PEER_LOG="${HOME}/.local/state/libreportal/peer-shell.log"
mkdir -p "$(dirname "$LP_PEER_LOG")" 2>/dev/null || true
_log() {
printf '%s peer=%s verb=%s detail=%s\n' \
"$(date -Iseconds)" "$LP_PEER_NAME" "$1" "${2:-}" >> "$LP_PEER_LOG" 2>/dev/null || true
}
_die() {
_log error "$1"
printf '{"error":"%s"}\n' "$1" >&2
exit 1
}
# Bootstrap shared LibrePortal config (containers_dir, manager helpers).
# Source the standard env if we can find it; otherwise infer paths.
if [[ -r "${HOME}/.libreportal-env" ]]; then
# shellcheck disable=SC1090
source "${HOME}/.libreportal-env" >/dev/null 2>&1 || true
fi
: "${containers_dir:=/libreportal-containers/}"
# Storage locations: peers can serve apps from any registered root, so resolve
# through paths.sh when it is reachable. This is a restricted SSH shell that may
# run with no LibrePortal env at all, so fall back to the single primary root —
# which is exactly what this script did before locations existed.
if [[ -n "${install_scripts_dir:-}" && -r "${install_scripts_dir}source/paths.sh" ]]; then
# shellcheck disable=SC1090
source "${install_scripts_dir}source/paths.sh" >/dev/null 2>&1 || true
fi
if ! declare -F storageAppDirs >/dev/null 2>&1; then
storageAppDirs() { find "${containers_dir%/}" -mindepth 1 -maxdepth 1 -type d 2>/dev/null; }
appDir() { printf '%s' "${containers_dir%/}/$1"; }
fi
CMD="${SSH_ORIGINAL_COMMAND:-}"
if [[ -z "$CMD" ]]; then
_die "no-command"
fi
# Parse: first token = verb, rest = args. No shell expansion of CMD's content.
read -r VERB ARGS <<< "$CMD"
# App-slug validator. Slugs are lowercase alnum + dash; anything else means
# someone's trying path traversal or shell injection through the args.
_valid_slug() {
[[ "$1" =~ ^[a-z0-9][a-z0-9-]{0,62}$ ]]
}
verb_ping() {
_log ping ""
printf '{"ok":true,"peer":"%s","time":"%s"}\n' \
"$LP_PEER_NAME" "$(date -Iseconds)"
}
verb_list_apps() {
_log list-apps ""
local first=1
printf '{"peer":"%s","apps":[' "$LP_PEER_NAME"
local d slug size_kb
while IFS= read -r d; do
[[ -d "$d" ]] || continue
d="${d%/}/"
slug=$(basename "$d")
[[ -f "${d}docker-compose.yml" || -f "${d}compose.yml" ]] || continue
_valid_slug "$slug" || continue
size_kb=$(du -sk "$d" 2>/dev/null | awk '{print $1}')
[[ -z "$size_kb" ]] && size_kb=0
(( first )) || printf ','
first=0
printf '{"slug":"%s","size_kb":%s}' "$slug" "$size_kb"
done < <(storageAppDirs)
printf ']}\n'
}
verb_stream_app() {
local slug
read -r slug <<< "$ARGS"
if [[ -z "$slug" ]] || ! _valid_slug "$slug"; then
_die "invalid-slug"
fi
if [[ ! -d "$(appDir "$slug")" ]]; then
_die "no-such-app"
fi
_log stream-app "$slug"
# Stream a tar of the app dir to stdout. --warning=no-file-changed because
# live data dirs change during read; we accept eventual consistency. The
# caller is the receiver's peer_pull.sh, which untars into a staging dir
# and then runs the migrate-flow.
# -C the root that actually holds this app: with storage locations it is not
# necessarily the primary one, and tarring from the wrong root silently
# streams nothing.
local _dir _root
_dir=$(appDir "$slug") || _die "storage-unavailable"
_root="${_dir%/*}"
tar --warning=no-file-changed --warning=no-file-removed \
-C "$_root" -cf - "$slug"
}
case "$VERB" in
ping) verb_ping ;;
list-apps) verb_list_apps ;;
stream-app) verb_stream_app ;;
*) _die "unknown-verb" ;;
esac