diff --git a/containers/libreportal/frontend/html/peers-content.html b/containers/libreportal/frontend/html/peers-content.html index ec50ef5..c8a6d83 100644 --- a/containers/libreportal/frontend/html/peers-content.html +++ b/containers/libreportal/frontend/html/peers-content.html @@ -25,12 +25,26 @@ Refresh + + @@ -62,3 +76,44 @@ + +
+
+
+

This host's pairing token

+ +
+
+ +
+
+ +
+
+
+

Pair with another LibrePortal

+ +
+
+ +
+
+ +
+
+
+

Pull app from peer

+ +
+
+ +
+
diff --git a/containers/libreportal/frontend/js/components/peers/peers-page.js b/containers/libreportal/frontend/js/components/peers/peers-page.js index 5024caa..f1d4cde 100644 --- a/containers/libreportal/frontend/js/components/peers/peers-page.js +++ b/containers/libreportal/frontend/js/components/peers/peers-page.js @@ -42,22 +42,24 @@ class PeersPage { setTimeout(() => this.refreshAll().then(() => this.render()), 2000); return; } - if (e.target.closest('#peers-add-btn')) { - this.openAddModal(); - return; - } - if (e.target.closest('#peers-add-confirm')) { - this.confirmAdd(); - return; - } + if (e.target.closest('#peers-add-btn')) { this.openAddModal(); return; } + if (e.target.closest('#peers-add-confirm')) { this.confirmAdd(); return; } + if (e.target.closest('#peers-token-btn')) { this.openTokenModal(); return; } + if (e.target.closest('#peers-pair-btn')) { this.openPairModal(); return; } + if (e.target.closest('#peers-pair-confirm')){ this.confirmPair(); return; } + if (e.target.closest('#peers-pull-confirm')){ this.confirmPull(); return; } const removeBtn = e.target.closest('[data-action="peer-remove"]'); - if (removeBtn) { - this.removePeer(removeBtn.dataset.name); + if (removeBtn) { this.removePeer(removeBtn.dataset.name); return; } + const checkBtn = e.target.closest('[data-action="peer-check"]'); + if (checkBtn) { this.checkPeer(checkBtn.dataset.name); return; } + const pullBtn = e.target.closest('[data-action="peer-pull"]'); + if (pullBtn) { + this.openPullModal(pullBtn.dataset.peer, pullBtn.dataset.app); return; } - const checkBtn = e.target.closest('[data-action="peer-check"]'); - if (checkBtn) { - this.checkPeer(checkBtn.dataset.name); + const appsBtn = e.target.closest('[data-action="peer-apps"]'); + if (appsBtn) { + this.fetchAndShowPeerApps(appsBtn.dataset.name); return; } if (e.target.closest('[data-close-modal]') || e.target.matches('.backup-modal')) { @@ -117,13 +119,207 @@ class PeersPage {
+ ${peer.kind === 'direct-ssh-direct' ? ` + + ` : ''}
+ `; } + /* --- pairing wizard --- */ + + async openTokenModal() { + const modal = document.getElementById('peers-token-modal'); + const body = document.getElementById('peers-token-modal-body'); + if (!modal || !body) return; + body.innerHTML = `

Fetching token…

`; + modal.classList.add('open'); + // Run via the task system so the keypair generation (first-time only) + // surfaces in the task log if it fails. + const id = await this.runTaskCapture('libreportal peer token', 'peer', null); + const token = await this.readTaskOutput(id); + if (!token || !token.trim().startsWith('lp-peer:')) { + body.innerHTML = `

Could not generate token. Check the task log.

`; + return; + } + const trimmed = token.trim(); + body.innerHTML = ` +

Copy this token and paste it into the other LibrePortal's Pair with token dialog. Symmetric — you'll need to do the same with theirs.

+ +

+ Anyone with this token can SSH in as the manager user with a forced-command (only the safe peer-shell verbs). + Treat it like a strong password — share only over a channel you trust (encrypted chat, password manager). +

+ `; + } + + openPairModal() { + const modal = document.getElementById('peers-pair-modal'); + const body = document.getElementById('peers-pair-modal-body'); + if (!modal || !body) return; + body.innerHTML = ` +
+
+ + +
+
+ + + Only set this if the token's name would collide with an existing peer here. +
+
+

+ Accepting authorizes the originator to call peer-shell as the manager user (locked-down: no shell, no + forwarding, only the whitelisted verbs). A peer record is also created so this host can pull from them. +

+ `; + modal.classList.add('open'); + } + + async confirmPair() { + const modal = document.getElementById('peers-pair-modal'); + if (!modal) return; + const token = modal.querySelector('#peer-pair-token')?.value?.trim(); + const override = modal.querySelector('#peer-pair-name')?.value?.trim(); + if (!token || !token.startsWith('lp-peer:')) { + this.notify('That doesn\'t look like a pairing token (should start with lp-peer:).', 'error'); + return; + } + this.closeAllModals(); + const cmd = override + ? `libreportal peer pair '${token.replace(/'/g, "'\\''")}' ${override}` + : `libreportal peer pair '${token.replace(/'/g, "'\\''")}'`; + await this.runTask(cmd, 'peer', null); + setTimeout(() => this.refreshAll().then(() => this.render()), 2000); + } + + /* --- direct-ssh peer: list-apps + pull --- */ + + async fetchAndShowPeerApps(peerName) { + const zone = document.querySelector(`.peers-apps-zone[data-peer="${CSS.escape(peerName)}"]`); + if (!zone) return; + zone.hidden = false; + zone.innerHTML = `

Fetching app list…

`; + const id = await this.runTaskCapture(`libreportal peer apps ${peerName}`, 'peer', null); + const out = await this.readTaskOutput(id); + let parsed; + try { parsed = JSON.parse(out); } catch { parsed = null; } + if (!parsed || !Array.isArray(parsed.apps)) { + zone.innerHTML = `

No app list — peer unreachable or returned malformed JSON. See task log.

`; + return; + } + if (!parsed.apps.length) { + zone.innerHTML = `

${this.escape(peerName)} has no apps installed.

`; + return; + } + zone.innerHTML = ` +
+ ${parsed.apps.map(a => ` +
+
+ ${this.escape(a.slug)} + ${this.formatBytes((a.size_kb || 0) * 1024)} +
+ +
+ `).join('')} +
+ `; + } + + openPullModal(peer, app) { + const modal = document.getElementById('peers-pull-modal'); + const body = document.getElementById('peers-pull-modal-body'); + if (!modal || !body) return; + body.innerHTML = ` +

Pull ${this.escape(app)} from peer ${this.escape(peer)}?

+

Streams the app's live data over SSH (via the peer-shell forced-command) and replaces this host's copy.

+
+ + +
+ `; + modal.dataset.peer = peer; + modal.dataset.app = app; + modal.classList.add('open'); + } + + async confirmPull() { + const modal = document.getElementById('peers-pull-modal'); + if (!modal) return; + const { peer, app } = modal.dataset; + const preBackup = document.getElementById('peer-pull-opt-pre-backup')?.checked; + const rewrite = document.getElementById('peer-pull-opt-rewrite')?.checked; + const opts = []; + if (preBackup === false) opts.push('--no-pre-backup'); + if (rewrite === false) opts.push('--keep-urls'); + const optStr = opts.length ? ' ' + opts.join(' ') : ''; + this.closeAllModals(); + await this.runTask(`libreportal peer pull ${peer} ${app}${optStr}`, 'peer', app); + } + + /* --- task-capture helpers (used by token/apps which need the output) --- */ + + async runTaskCapture(command, type, app) { + // Same as runTask but returns the task id so the caller can read its log. + if (!this.taskManager) { + this.notify('Task system unavailable', 'error'); + return null; + } + try { + const t = await this.taskManager.createTask(command, type, app); + return t?.id || null; + } catch (err) { + this.notify(`Failed to queue task: ${err.message || err}`, 'error'); + return null; + } + } + + async readTaskOutput(taskId, maxWaitMs = 8000) { + // Poll the task's log until it completes or we hit a cap. Fast verbs + // (token, apps) finish in well under a second; this is the cheapest + // way to surface their stdout without subscribing to SSE. + if (!taskId) return ''; + const start = Date.now(); + while (Date.now() - start < maxWaitMs) { + try { + const r = await fetch(`/api/tasks/${taskId}`); + if (r.ok) { + const t = await r.json(); + if (t.status === 'completed' || t.status === 'failed') { + const lr = await fetch(`/api/tasks/${taskId}/log`); + if (lr.ok) return await lr.text(); + return ''; + } + } + } catch { /* network blip — retry */ } + await new Promise(res => setTimeout(res, 250)); + } + return ''; + } + + formatBytes(b) { + if (!b || b < 1024) return `${b || 0} B`; + const u = ['KB','MB','GB','TB']; let i = -1; let v = b; + do { v /= 1024; i++; } while (v >= 1024 && i < u.length - 1); + return `${v.toFixed(v >= 10 ? 0 : 1)} ${u[i]}`; + } + summariseConfig(kind, cfg) { if (kind === 'backup-channel') { const host = cfg.hostname ? `hostname=${this.escape(cfg.hostname)}` : 'no hostname set'; diff --git a/scripts/cli/commands/peer/cli_peer_commands.sh b/scripts/cli/commands/peer/cli_peer_commands.sh index bfe624c..5fb25ce 100644 --- a/scripts/cli/commands/peer/cli_peer_commands.sh +++ b/scripts/cli/commands/peer/cli_peer_commands.sh @@ -37,6 +37,25 @@ cliHandlePeerCommands() peerCheckReachable "$arg1" fi ;; + token) + # Emit this host's pairing token. User shares it OOB with the peer. + peerPairingToken + ;; + pair) + # peer pair [override-name] + [[ -z "$arg1" ]] && { isNotice "Usage: peer pair [override-name]"; return; } + peerPairingAccept "$arg1" "$arg2" + ;; + pull) + # peer pull [--no-pre-backup] [--keep-urls] + [[ -z "$arg1" || -z "$arg2" ]] && { isNotice "Usage: peer pull [--no-pre-backup] [--keep-urls]"; return; } + peerPullApp "$arg1" "$arg2" "$arg3" "$arg4" + ;; + apps) + # peer apps — live list of apps the peer has (calls peer-shell list-apps) + [[ -z "$arg1" ]] && { isNotice "Usage: peer apps "; return; } + peerListAppsRemote "$arg1" + ;; *) isNotice "Invalid peer action: $action" cliShowPeerHelp diff --git a/scripts/cli/commands/peer/cli_peer_header.sh b/scripts/cli/commands/peer/cli_peer_header.sh index 36d8a63..00823eb 100644 --- a/scripts/cli/commands/peer/cli_peer_header.sh +++ b/scripts/cli/commands/peer/cli_peer_header.sh @@ -23,7 +23,27 @@ cliShowPeerHelp() echo " Reachability probe. With , checks one; without, all. Updates" echo " the peer's status + last_seen columns." echo "" + echo "peer token" + echo " Print THIS host's pairing token. Hand it to another LibrePortal" + echo " so it can authorize this host. Symmetric — both sides accept the" + echo " other's token to enable bidirectional peer-shell calls." + echo "" + echo "peer pair [override-name]" + echo " Accept a pairing token. Installs peer-shell, appends to" + echo " authorized_keys with forced-command + lockdown options, and" + echo " creates a kind=direct-ssh-direct peer row pointing at the" + echo " originator." + echo "" + echo "peer apps " + echo " Live list of apps the peer has installed (via peer-shell)." + echo "" + echo "peer pull [--no-pre-backup] [--keep-urls]" + echo " Pull 's live data from the peer over SSH and replace this" + echo " host's copy. Same defaults as 'restore migrate app' (pre-backup" + echo " ON, URL rewrite ON) so you can roll back if the pull misbehaves." + echo "" echo "Notes:" - echo " • Today only kind=backup-channel works. direct-ssh-direct and" - echo " direct-ssh-via-relay (Connect blind-relay) ship with Phase 3." + echo " • kind=backup-channel (Phase 1/2) and kind=direct-ssh-direct" + echo " (Phase 3) are live. kind=direct-ssh-via-relay (Connect blind" + echo " relay) waits for Connect itself to ship." } diff --git a/scripts/peer/peer_add.sh b/scripts/peer/peer_add.sh index a30f12b..91538c9 100644 --- a/scripts/peer/peer_add.sh +++ b/scripts/peer/peer_add.sh @@ -13,6 +13,13 @@ peerAdd() { local name="$1"; shift local kind="$1"; shift + # Some callers (CLI dispatcher) pass empty trailing args from + # initial_command slots; strip them so they don't show up in config_json + # as empty entries. + local _cleaned=() + local _a + for _a in "$@"; do [[ -n "$_a" ]] && _cleaned+=("$_a"); done + set -- "${_cleaned[@]}" local nv; nv=$(peerValidateName "$name") if [[ "$nv" != "ok" ]]; then diff --git a/scripts/peer/peer_check.sh b/scripts/peer/peer_check.sh index b08d634..a56aa44 100644 --- a/scripts/peer/peer_check.sh +++ b/scripts/peer/peer_check.sh @@ -54,8 +54,20 @@ peerCheckReachable() fi fi ;; - direct-ssh-direct|direct-ssh-via-relay) - new_status="not-yet-implemented" + direct-ssh-direct) + # peerPing already updates the row + returns the status name on + # stdout. We re-read from the DB at the bottom of the function so + # callers see the same value. + new_status=$(peerPing "$name" 2>/dev/null) + [[ -z "$new_status" ]] && new_status="unreachable" + # peerPing wrote status + last_seen already; short-circuit the + # second UPDATE below. + echo "$new_status" + [[ "$new_status" == "ok" ]] + return $? + ;; + direct-ssh-via-relay) + new_status="needs-connect" ;; *) new_status="unknown-kind" diff --git a/scripts/peer/peer_helpers.sh b/scripts/peer/peer_helpers.sh index e9246a3..a1496aa 100644 --- a/scripts/peer/peer_helpers.sh +++ b/scripts/peer/peer_helpers.sh @@ -33,16 +33,15 @@ peerValidateName() echo "ok" } -# Validate kind. Phase 2 only allows 'backup-channel'; the others are accepted -# at the schema level but the bash helpers reject them until Phase 3 ships -# their support to avoid users adding peers that nothing knows how to use. +# Validate kind. backup-channel (Phase 1/2) and direct-ssh-direct (Phase 3) +# are live; direct-ssh-via-relay needs Connect to exist (Phase 3b). peerValidateKind() { local kind="$1" case "$kind" in - backup-channel) echo "ok" ;; - direct-ssh-direct|direct-ssh-via-relay) - echo "not-yet-implemented"; return 1 ;; + backup-channel|direct-ssh-direct) echo "ok" ;; + direct-ssh-via-relay) + echo "needs-connect"; return 1 ;; *) echo "unknown-kind"; return 1 ;; esac } diff --git a/scripts/peer/peer_install_shell.sh b/scripts/peer/peer_install_shell.sh new file mode 100644 index 0000000..267a6f8 --- /dev/null +++ b/scripts/peer/peer_install_shell.sh @@ -0,0 +1,41 @@ +#!/bin/bash + +# Deploys peer-shell (the forced-command dispatcher) to a stable path under +# the manager user's home so authorized_keys can point at it. Idempotent — +# safe to call on every peer pairing. Also writes ~/.libreportal-env so +# peer-shell can find containers_dir without bootstrapping the full repo. + +_peerShellPath() { echo "${HOME}/.local/bin/peer-shell"; } +_peerShellSrc() { echo "${install_scripts_dir}peer/peer_shell.sh"; } +_peerEnvPath() { echo "${HOME}/.libreportal-env"; } + +peerInstallShell() +{ + local dest src env + dest=$(_peerShellPath) + src=$(_peerShellSrc) + env=$(_peerEnvPath) + + if [[ ! -f "$src" ]]; then + isError "peer-shell source missing at $src" + return 1 + fi + + mkdir -p "$(dirname "$dest")" + + # Copy + chmod. Compare first so we don't rewrite on every call. + if [[ ! -f "$dest" ]] || ! cmp -s "$src" "$dest"; then + cp "$src" "$dest" + chmod 700 "$dest" + isSuccessful "Installed peer-shell to $dest" + fi + + # Write the env file peer-shell sources. Just enough for it to locate the + # container tree. We re-emit on every call because containers_dir may + # change with a relocation. + { + printf '# Auto-written by peerInstallShell — do not edit.\n' + printf 'containers_dir=%q\n' "${containers_dir:-/libreportal-containers/}" + } > "$env" + chmod 600 "$env" +} diff --git a/scripts/peer/peer_key.sh b/scripts/peer/peer_key.sh new file mode 100644 index 0000000..0c21991 --- /dev/null +++ b/scripts/peer/peer_key.sh @@ -0,0 +1,55 @@ +#!/bin/bash + +# Manage the manager-user's peer SSH keypair. One keypair per LibrePortal +# install — used as the *outbound* identity when this host SSHes into a +# direct-ssh peer, AND the public half is what other instances paste into +# their pairing wizard to authorize this host. +# +# Lives under ~/.ssh/libreportal-peer{,.pub} so it sits alongside the other +# manager keys without polluting id_rsa/id_ed25519. + +_peerKeyDir() { echo "${HOME}/.ssh"; } +_peerKeyPrivPath() { echo "$(_peerKeyDir)/libreportal-peer"; } +_peerKeyPubPath() { echo "$(_peerKeyDir)/libreportal-peer.pub"; } + +# Generate the keypair if it doesn't exist. Idempotent. +peerKeyEnsure() +{ + local dir; dir=$(_peerKeyDir) + local priv; priv=$(_peerKeyPrivPath) + local pub; pub=$(_peerKeyPubPath) + + if [[ ! -d "$dir" ]]; then + mkdir -p "$dir" + chmod 700 "$dir" + fi + if [[ -f "$priv" && -f "$pub" ]]; then + return 0 + fi + + isNotice "Generating LibrePortal peer keypair (one-time, ed25519)" + ssh-keygen -t ed25519 -N '' -f "$priv" -C "libreportal-peer@${CFG_INSTALL_NAME:-$(hostname)}" >/dev/null 2>&1 + if [[ $? -ne 0 || ! -f "$priv" ]]; then + isError "ssh-keygen failed — peer features unavailable" + return 1 + fi + chmod 600 "$priv" + chmod 644 "$pub" + isSuccessful "Peer keypair at $priv" +} + +# Echo the local peer pubkey (one line). Empty if not generated yet. +peerKeyPublic() +{ + local pub; pub=$(_peerKeyPubPath) + [[ -f "$pub" ]] || return 1 + cat "$pub" +} + +# Echo the SHA256 fingerprint of the local peer key (matches ssh-keygen -l). +peerKeyFingerprint() +{ + local pub; pub=$(_peerKeyPubPath) + [[ -f "$pub" ]] || return 1 + ssh-keygen -l -f "$pub" 2>/dev/null | awk '{print $2}' +} diff --git a/scripts/peer/peer_pairing.sh b/scripts/peer/peer_pairing.sh new file mode 100644 index 0000000..91f4e2a --- /dev/null +++ b/scripts/peer/peer_pairing.sh @@ -0,0 +1,162 @@ +#!/bin/bash + +# Pairing token — the out-of-band string two LibrePortal instances exchange +# so each can talk to the other over SSH with a forced-command. Format: +# +# lp-peer|v1|||||| +# +# Pipe-delimited because SSH fingerprints (SHA256:base64) and base64 itself +# include `:` and `=`, so colon-splitting eats fields. Pipe is safe: base64 +# alphabet is [A-Za-z0-9+/=], fingerprints are SHA256:[A-Za-z0-9+/=], names +# and hostnames don't contain pipes. +# +# - name Friendly label the OTHER side suggests for itself ("homelab"). +# The accepting side may override at peer-add time. +# - user Manager username on the originating host (the SSH login). +# - host:port How to reach the originating host. port defaults to 22. +# - pubkey base64 of "ssh-ed25519 AAAA… comment" — the originator's +# peer pubkey (peerKeyPublic). +# - fingerprint SHA256:… of the pubkey, for the accepting user to verify. + +peerPairingToken() +{ + peerKeyEnsure || return 1 + + local name="${CFG_INSTALL_NAME:-$(hostname)}" + local user + user="$(whoami)" + local host="${CFG_PUBLIC_HOSTNAME:-${CFG_INSTALL_NAME:-$(hostname)}}" + local port="${CFG_SSH_PORT:-22}" + + local pub fp pub_b64 + pub=$(peerKeyPublic) || { isError "Could not read peer pubkey"; return 1; } + fp=$(peerKeyFingerprint) + # base64 -w0 keeps the encoded pubkey on a single token field. The pubkey + # itself already contains spaces — we MUST encode it. + pub_b64=$(printf '%s' "$pub" | base64 -w0) + + printf 'lp-peer|v1|%s|%s|%s|%s|%s|%s\n' \ + "$name" "$user" "$host" "$port" "$pub_b64" "$fp" +} + +# Parse a token. Echoes a single-line JSON object on success, JSON {"error":…} +# on failure (return 1). +peerPairingParse() +{ + local token="$1" + if [[ -z "$token" || "$token" != lp-peer\|* ]]; then + echo '{"error":"not-a-token"}' + return 1 + fi + + # Pipe-split. Easy now: every field is just "${arr[N]}". + local IFS='|' + # shellcheck disable=SC2206 + local parts=( $token ) + IFS=$' \t\n' + + if [[ ${#parts[@]} -lt 8 || "${parts[0]}" != "lp-peer" ]]; then + echo '{"error":"malformed"}' + return 1 + fi + local version="${parts[1]}" + if [[ "$version" != "v1" ]]; then + echo "{\"error\":\"unsupported-version\",\"version\":\"$version\"}" + return 1 + fi + local name="${parts[2]}" + local user="${parts[3]}" + local host="${parts[4]}" + local port="${parts[5]}" + local pubkey_b64="${parts[6]}" + local fp="${parts[7]}" + + local pubkey + pubkey=$(printf '%s' "$pubkey_b64" | base64 -d 2>/dev/null) + if [[ -z "$pubkey" ]] || ! printf '%s\n' "$pubkey" | ssh-keygen -l -f - >/dev/null 2>&1; then + echo '{"error":"invalid-pubkey"}' + return 1 + fi + + # Re-derive fingerprint from the actual key and confirm it matches what + # the token advertised — catches truncated/edited tokens. + local actual_fp + actual_fp=$(printf '%s' "$pubkey" | ssh-keygen -l -f - 2>/dev/null | awk '{print $2}') + if [[ -n "$fp" && "$actual_fp" != "$fp" ]]; then + echo "{\"error\":\"fingerprint-mismatch\",\"advertised\":\"$fp\",\"actual\":\"$actual_fp\"}" + return 1 + fi + + local pub_esc="${pubkey//\\/\\\\}"; pub_esc="${pub_esc//\"/\\\"}" + printf '{"version":"v1","name":"%s","user":"%s","host":"%s","port":%s,"pubkey":"%s","fingerprint":"%s"}\n' \ + "$name" "$user" "$host" "$port" "$pub_esc" "$actual_fp" +} + +# Pull a "key":"value" string field out of a flat JSON object. No jq. +_peerPairingJsonStr() +{ + printf '%s' "$1" | grep -o "\"$2\":\"[^\"]*\"" | head -1 | cut -d'"' -f4 +} +# Pull a "key":N numeric field out of a flat JSON object. +_peerPairingJsonNum() +{ + printf '%s' "$1" | grep -o "\"$2\":[0-9]*" | head -1 | cut -d':' -f2 +} + +# Accept a pairing token: validate, install peer-shell, append authorized_keys +# entry with forced-command, create peers row. The local label can override +# the token's suggested name (handy when there's a collision). +peerPairingAccept() +{ + local token="$1" + local override_name="$2" + + local parsed; parsed=$(peerPairingParse "$token") + if [[ "$parsed" == *'"error"'* ]]; then + isError "Token rejected: $parsed" + return 1 + fi + + local name pubkey host port user + name=$(_peerPairingJsonStr "$parsed" name) + pubkey=$(_peerPairingJsonStr "$parsed" pubkey) + host=$(_peerPairingJsonStr "$parsed" host) + port=$(_peerPairingJsonNum "$parsed" port) + user=$(_peerPairingJsonStr "$parsed" user) + + [[ -n "$override_name" ]] && name="$override_name" + + local nv; nv=$(peerValidateName "$name") + if [[ "$nv" != "ok" ]]; then + isError "Invalid peer name '$name': $nv" + return 1 + fi + + # Install peer-shell and append the authorized_keys entry (this lets the + # remote peer SSH IN to us). Adding the peer to OUR DB lets us SSH OUT to + # them — symmetric pairing means the user runs accept on both sides. + peerInstallShell || return 1 + + local akf="${HOME}/.ssh/authorized_keys" + mkdir -p "${HOME}/.ssh" + chmod 700 "${HOME}/.ssh" + touch "$akf" + chmod 600 "$akf" + + # If the key body is already in authorized_keys (under any options), + # don't double-add — but DO refresh the comment to flag it as a peer. + local key_body; key_body=$(awk '{print $2}' <<< "$pubkey") + if grep -qF "$key_body" "$akf"; then + isNotice "Pubkey already in authorized_keys — not duplicating" + else + local shell_path; shell_path="${HOME}/.local/bin/peer-shell" + local opts="command=\"$shell_path $name\",no-pty,no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-user-rc" + printf '%s %s peer:%s\n' "$opts" "$pubkey" "$name" >> "$akf" + isSuccessful "Authorized peer '$name' to call peer-shell" + fi + + # Now record the peer locally so we can SSH OUT to them. + peerAdd "$name" direct-ssh-direct \ + "host=$host" "port=$port" "user=$user" "fingerprint=$(_peerPairingJsonStr "$parsed" fingerprint)" \ + || return 1 +} diff --git a/scripts/peer/peer_pull.sh b/scripts/peer/peer_pull.sh new file mode 100644 index 0000000..0299609 --- /dev/null +++ b/scripts/peer/peer_pull.sh @@ -0,0 +1,124 @@ +#!/bin/bash + +# Pull an app from a direct-ssh-direct peer onto this host. End-to-end: +# +# 1. Resolve peer + verify reachable (peerPing). +# 2. Take the existing-app safety backup (migratePreBackupDestination) so +# a bad pull is rollback-able via the normal restore flow. +# 3. Stop + wipe the destination's app folder (same as restoreAppStart). +# 4. Stream `peer-shell stream-app ` over SSH and untar into +# containers_dir/. +# 5. Reuse the install-time tag pipeline to repoint host-bound values: +# - dockerComposeUpdateAndStartApp install re-emits compose +# - migrateApplyUrlRewrite rewrites URLs +# 6. Start the container, run app-specific post-restore hooks. +# +# That gives us the same idempotent install path the restic-mediated +# migrateApplyApp uses, just with tar-over-SSH as the data source instead +# of a restic snapshot. + +# peerPullApp [--no-pre-backup] [--keep-urls] +peerPullApp() +{ + local peer_name="$1"; shift + local app="$1"; shift + + # Reuse migrate_apply's opt parser to keep flag semantics identical. + _migrateParseOpts "$@" + + if [[ -z "$peer_name" || -z "$app" ]]; then + isError "peerPullApp: peer_name and app required" + return 1 + fi + + local row; row=$(peerGet "$peer_name") + if [[ -z "$row" || "$row" == "null" ]]; then + isError "No peer named '$peer_name'" + return 1 + fi + local kind + kind=$(printf '%s' "$row" | grep -o '"kind":"[^"]*"' | head -1 | cut -d'"' -f4) + if [[ "$kind" != "direct-ssh-direct" ]]; then + isError "Peer '$peer_name' is kind=$kind — peerPullApp needs direct-ssh-direct" + return 1 + fi + + local started_at; started_at=$(date +%s) + isHeader "Pull $app from peer=$peer_name → this host" + migrateEmit phase=start status=running app="$app" peer="$peer_name" transport=direct-ssh + + # ---- 1. Reachability -------------------------------------------------- + migrateEmit phase=ping status=running peer="$peer_name" + local ping_status; ping_status=$(peerPing "$peer_name") + if [[ "$ping_status" != "ok" ]]; then + isError "Peer '$peer_name' unreachable: $ping_status" + migrateEmit phase=ping status=failed detail="$ping_status" + return 1 + fi + migrateEmit phase=ping status=complete + + # ---- 2. Pre-backup of destination (default ON) ------------------------ + if (( MIGRATE_OPT_PRE_BACKUP )); then + migratePreBackupDestination "$app" + else + migrateEmit phase=pre-backup status=skipped reason=user-opt-out app="$app" + fi + + # ---- 3. Stop + wipe ---------------------------------------------------- + migrateEmit phase=stop status=running app="$app" + if declare -f dockerComposeDown >/dev/null 2>&1 && [[ -d "$containers_dir$app" ]]; then + dockerComposeDown "$app" >/dev/null 2>&1 || true + fi + if [[ -d "$containers_dir$app" ]]; then + runFileOp rm -rf "${containers_dir:?}$app" + fi + migrateEmit phase=stop status=complete app="$app" + + # ---- 4. Stream tar over SSH and untar --------------------------------- + migrateEmit phase=transfer status=running app="$app" + # The pipe gives us streaming throughput without staging the whole tarball + # to disk. set -o pipefail catches ssh failures so we don't run the rest + # of the flow on a partial extract. + ( + set -o pipefail + peerExec "$peer_name" "stream-app $app" | tar -C "$containers_dir" -xf - + ) + local transfer_rc=$? + if (( transfer_rc != 0 )); then + isError "Transfer of $app from $peer_name failed (rc=$transfer_rc)" + migrateEmit phase=transfer status=failed rc="$transfer_rc" + return 1 + fi + if [[ ! -d "$containers_dir$app" ]]; then + isError "Transfer reported success but $containers_dir$app missing" + migrateEmit phase=transfer status=failed reason=missing-output + return 1 + fi + runFileOp chown -R "${docker_install_user:-$(whoami)}":"${docker_install_user:-$(whoami)}" "$containers_dir$app" 2>/dev/null || true + migrateEmit phase=transfer status=complete app="$app" + + # ---- 5. URL rewrite (default ON) + re-deploy compose ------------------- + if ! (( MIGRATE_OPT_KEEP_URLS )); then + migrateApplyUrlRewrite "$app" + else + migrateEmit phase=url-rewrite status=skipped reason=user-opt-out app="$app" + fi + if declare -f dockerComposeUpdateAndStartApp >/dev/null 2>&1; then + dockerComposeUpdateAndStartApp "$app" install >/dev/null 2>&1 || true + fi + + # ---- 6. Start + post-restore hooks ------------------------------------ + migrateEmit phase=start-app status=running app="$app" + if declare -f dockerComposeUp >/dev/null 2>&1; then + dockerComposeUp "$app" >/dev/null 2>&1 || true + fi + if declare -f restoreAppRunHook >/dev/null 2>&1; then + restoreAppRunHook "$app" post || true + fi + migrateEmit phase=start-app status=complete app="$app" + + local finished_at; finished_at=$(date +%s) + local duration=$((finished_at - started_at)) + isSuccessful "Pulled $app from $peer_name in ${duration}s" + migrateEmit phase=done status=complete app="$app" duration_seconds="$duration" +} diff --git a/scripts/peer/peer_remote.sh b/scripts/peer/peer_remote.sh new file mode 100644 index 0000000..14a03f8 --- /dev/null +++ b/scripts/peer/peer_remote.sh @@ -0,0 +1,82 @@ +#!/bin/bash + +# Receiver-side helpers: invoke peer-shell over SSH and consume its output. +# These run on the local instance (the one initiating the pull) and connect +# OUT to a direct-ssh-direct peer using the keypair from peerKeyEnsure. + +# Inner: SSH options. Strict key check is on; the peer's host key gets +# learned/stored in ~/.ssh/known_hosts on first connection. Future hardening +# could bind to the fingerprint we stored at pairing time. +_peerSshOpts() +{ + local priv; priv="${HOME}/.ssh/libreportal-peer" + echo "-i" "$priv" "-o" "BatchMode=yes" "-o" "StrictHostKeyChecking=accept-new" \ + "-o" "PasswordAuthentication=no" "-o" "ConnectTimeout=10" +} + +# Read a peer's connection config from its peers row. +# Echos: "@ " or empty on miss. +_peerSshTarget() +{ + local peer_name="$1" + local row; row=$(peerGet "$peer_name") + [[ -z "$row" || "$row" == "null" ]] && return 1 + local host port user + host=$(printf '%s' "$row" | grep -o '"host":"[^"]*"' | head -1 | cut -d'"' -f4) + port=$(printf '%s' "$row" | grep -o '"port":[0-9]*' | head -1 | cut -d':' -f2) + user=$(printf '%s' "$row" | grep -o '"user":"[^"]*"' | head -1 | cut -d'"' -f4) + [[ -z "$host" || -z "$user" ]] && return 1 + [[ -z "$port" ]] && port=22 + printf '%s@%s %s\n' "$user" "$host" "$port" +} + +# peerExec [args...] +# Runs `peer-shell ` on the peer over SSH and prints its output. +# Returns the SSH exit status. +peerExec() +{ + local peer_name="$1"; shift + local target; target=$(_peerSshTarget "$peer_name") || { + isError "Peer '$peer_name' has no SSH connection config" + return 1 + } + local user_host port + read -r user_host port <<< "$target" + + # The remote side has the forced-command in authorized_keys, so what we + # type after `ssh user@host` is ignored as a command and passed as + # $SSH_ORIGINAL_COMMAND. Stringify the verb + args. + local remote_cmd="$*" + ssh $(_peerSshOpts) -p "$port" "$user_host" "$remote_cmd" +} + +# Probe a peer by calling its peer-shell ping verb. Updates the peers row +# status + last_seen. Echos the new status. +peerPing() +{ + local peer_name="$1" + local out + out=$(peerExec "$peer_name" ping 2>&1) + local rc=$? + local status now + now=$(date -Iseconds) + if (( rc == 0 )) && [[ "$out" == *'"ok":true'* ]]; then + status="ok" + elif (( rc != 0 )); then + status="unreachable" + else + status="protocol-error" + fi + sqlite3 "$(_peerDb)" \ + "UPDATE peers SET status='$(peerSqlEscape "$status")', last_seen='$now' WHERE name='$(peerSqlEscape "$peer_name")';" \ + 2>/dev/null + echo "$status" + [[ "$status" == "ok" ]] +} + +# Fetch the peer's app list. Just relays the JSON peer-shell emits. +peerListAppsRemote() +{ + local peer_name="$1" + peerExec "$peer_name" list-apps +} diff --git a/scripts/peer/peer_shell.sh b/scripts/peer/peer_shell.sh new file mode 100644 index 0000000..2f3c380 --- /dev/null +++ b/scripts/peer/peer_shell.sh @@ -0,0 +1,106 @@ +#!/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 diff --git a/scripts/source/files/arrays/files_peer.sh b/scripts/source/files/arrays/files_peer.sh index 6e76d4a..e7e9de4 100644 --- a/scripts/source/files/arrays/files_peer.sh +++ b/scripts/source/files/arrays/files_peer.sh @@ -7,7 +7,12 @@ peer_scripts=( "peer/peer_add.sh" "peer/peer_check.sh" "peer/peer_helpers.sh" + "peer/peer_install_shell.sh" + "peer/peer_key.sh" "peer/peer_list.sh" + "peer/peer_pairing.sh" + "peer/peer_pull.sh" + "peer/peer_remote.sh" "peer/peer_remove.sh" ) diff --git a/scripts/source/files/generate_arrays.sh b/scripts/source/files/generate_arrays.sh index 7143d57..b483912 100755 --- a/scripts/source/files/generate_arrays.sh +++ b/scripts/source/files/generate_arrays.sh @@ -110,6 +110,19 @@ EOF ;; esac fi + + # peer/peer_shell.sh is the SSH forced-command dispatcher — it's + # deployed standalone to ~/.local/bin/peer-shell and executed by + # sshd, never sourced as a library. Sourcing it would run its + # top-level _die() on the empty SSH_ORIGINAL_COMMAND and crash + # the parent shell. + if [ "$folder_name" = "peer" ]; then + case "$rel_path" in + "peer/peer_shell.sh") + continue + ;; + esac + fi # Escape quotes and add to array echo " \"$rel_path\"" >> "$array_file"