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
+
+
+
+
+
+ Show my token
+
+
+
+
+
+
+ Pair with token
+
- Add peer
+ Add backup-channel peer
@@ -62,3 +76,44 @@
+
+
+
+
+
+
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 {
Check
+ ${peer.kind === 'direct-ssh-direct' ? `
+ List apps
+ ` : ''}
Remove
+
`;
}
+ /* --- 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 = `
+
+
+ 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)}
+
+
Pull
+
+ `).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.
+
+
+
+ Back up the destination's existing copy first
+ Safety net — snapshots ${this.escape(app)} into your first enabled backup location, tagged pre-migrate.
+
+
+
+
+ Rewrite host-bound URLs to this host
+ Replaces CFG_*_URL / *_DOMAIN / *_HOSTNAME with this host's values after the transfer.
+
+
+
+ `;
+ 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"