#!/bin/bash # peer-shell — the forced-command dispatcher invoked by an incoming SSH peer. # # Deployed by peerInstallShell() to ~/.local/bin/peer-shell, then # every incoming peer key in authorized_keys has: # # command="~/.local/bin/peer-shell ",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/}" 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 for d in "$containers_dir"*/; do [[ -d "$d" ]] || continue 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 printf ']}\n' } verb_stream_app() { local slug read -r slug <<< "$ARGS" if [[ -z "$slug" ]] || ! _valid_slug "$slug"; then _die "invalid-slug" fi if [[ ! -d "${containers_dir}${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. tar --warning=no-file-changed --warning=no-file-removed \ -C "$containers_dir" -cf - "$slug" } case "$VERB" in ping) verb_ping ;; list-apps) verb_list_apps ;; stream-app) verb_stream_app ;; *) _die "unknown-verb" ;; esac