Merge claude/2

This commit is contained in:
librelad 2026-05-26 17:56:57 +01:00
commit e9e29ba703
15 changed files with 920 additions and 24 deletions

View File

@ -25,12 +25,26 @@
</svg> </svg>
Refresh Refresh
</button> </button>
<button class="backup-secondary-btn" id="peers-token-btn" title="Show this host's pairing token">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2"></rect>
<path d="M7 11V7a5 5 0 0110 0v4"></path>
</svg>
Show my token
</button>
<button class="backup-secondary-btn" id="peers-pair-btn" title="Paste a token from another LibrePortal">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07L11.5 5.45"></path>
<path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07L12.5 18.55"></path>
</svg>
Pair with token
</button>
<button class="backup-primary-btn" id="peers-add-btn"> <button class="backup-primary-btn" id="peers-add-btn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"></line> <line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line> <line x1="5" y1="12" x2="19" y2="12"></line>
</svg> </svg>
Add peer Add backup-channel peer
</button> </button>
</div> </div>
</div> </div>
@ -62,3 +76,44 @@
</div> </div>
</div> </div>
</div> </div>
<div class="backup-modal" id="peers-token-modal">
<div class="backup-modal-inner backup-modal-wide">
<div class="backup-modal-header">
<h3>This host's pairing token</h3>
<button class="backup-modal-close" data-close-modal>×</button>
</div>
<div class="backup-modal-body" id="peers-token-modal-body"></div>
<div class="backup-modal-footer">
<button class="backup-secondary-btn" data-close-modal>Done</button>
</div>
</div>
</div>
<div class="backup-modal" id="peers-pair-modal">
<div class="backup-modal-inner backup-modal-wide">
<div class="backup-modal-header">
<h3>Pair with another LibrePortal</h3>
<button class="backup-modal-close" data-close-modal>×</button>
</div>
<div class="backup-modal-body" id="peers-pair-modal-body"></div>
<div class="backup-modal-footer">
<button class="backup-secondary-btn" data-close-modal>Cancel</button>
<button class="backup-primary-btn" id="peers-pair-confirm">Accept token</button>
</div>
</div>
</div>
<div class="backup-modal" id="peers-pull-modal">
<div class="backup-modal-inner">
<div class="backup-modal-header">
<h3>Pull app from peer</h3>
<button class="backup-modal-close" data-close-modal>×</button>
</div>
<div class="backup-modal-body" id="peers-pull-modal-body"></div>
<div class="backup-modal-footer">
<button class="backup-secondary-btn" data-close-modal>Cancel</button>
<button class="backup-primary-btn" id="peers-pull-confirm">Pull app</button>
</div>
</div>
</div>

View File

@ -42,22 +42,24 @@ class PeersPage {
setTimeout(() => this.refreshAll().then(() => this.render()), 2000); setTimeout(() => this.refreshAll().then(() => this.render()), 2000);
return; return;
} }
if (e.target.closest('#peers-add-btn')) { if (e.target.closest('#peers-add-btn')) { this.openAddModal(); return; }
this.openAddModal(); if (e.target.closest('#peers-add-confirm')) { this.confirmAdd(); return; }
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-add-confirm')) { if (e.target.closest('#peers-pair-confirm')){ this.confirmPair(); return; }
this.confirmAdd(); if (e.target.closest('#peers-pull-confirm')){ this.confirmPull(); return; }
return;
}
const removeBtn = e.target.closest('[data-action="peer-remove"]'); const removeBtn = e.target.closest('[data-action="peer-remove"]');
if (removeBtn) { if (removeBtn) { this.removePeer(removeBtn.dataset.name); return; }
this.removePeer(removeBtn.dataset.name); 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; return;
} }
const checkBtn = e.target.closest('[data-action="peer-check"]'); const appsBtn = e.target.closest('[data-action="peer-apps"]');
if (checkBtn) { if (appsBtn) {
this.checkPeer(checkBtn.dataset.name); this.fetchAndShowPeerApps(appsBtn.dataset.name);
return; return;
} }
if (e.target.closest('[data-close-modal]') || e.target.matches('.backup-modal')) { if (e.target.closest('[data-close-modal]') || e.target.matches('.backup-modal')) {
@ -117,13 +119,207 @@ class PeersPage {
</div> </div>
<div style="display:flex; gap:8px; flex-shrink:0"> <div style="display:flex; gap:8px; flex-shrink:0">
<button class="backup-secondary-btn" data-action="peer-check" data-name="${this.escape(peer.name)}">Check</button> <button class="backup-secondary-btn" data-action="peer-check" data-name="${this.escape(peer.name)}">Check</button>
${peer.kind === 'direct-ssh-direct' ? `
<button class="backup-secondary-btn" data-action="peer-apps" data-name="${this.escape(peer.name)}">List apps</button>
` : ''}
<button class="backup-danger-btn" data-action="peer-remove" data-name="${this.escape(peer.name)}">Remove</button> <button class="backup-danger-btn" data-action="peer-remove" data-name="${this.escape(peer.name)}">Remove</button>
</div> </div>
</div> </div>
<div class="peers-apps-zone" data-peer="${this.escape(peer.name)}" hidden style="margin-top:12px"></div>
</div> </div>
`; `;
} }
/* --- 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 = `<p class="backup-card-hint">Fetching token…</p>`;
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 = `<p style="color:var(--danger,#dc2626)">Could not generate token. Check the task log.</p>`;
return;
}
const trimmed = token.trim();
body.innerHTML = `
<p>Copy this token and paste it into the other LibrePortal's <strong>Pair with token</strong> dialog. Symmetric — you'll need to do the same with theirs.</p>
<textarea class="form-control" readonly style="width:100%; min-height:140px; font-family:monospace; font-size:.85em; word-break:break-all">${this.escape(trimmed)}</textarea>
<p class="backup-card-hint" style="margin-top:8px">
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).
</p>
`;
}
openPairModal() {
const modal = document.getElementById('peers-pair-modal');
const body = document.getElementById('peers-pair-modal-body');
if (!modal || !body) return;
body.innerHTML = `
<div class="backup-form-grid">
<div>
<label for="peer-pair-token">Pairing token from the other LibrePortal</label>
<textarea class="form-control" id="peer-pair-token" placeholder="lp-peer:v1:…" style="min-height:120px; font-family:monospace; font-size:.85em"></textarea>
</div>
<div>
<label for="peer-pair-name">Local label (optional)</label>
<input type="text" class="form-control" id="peer-pair-name" placeholder="Override the name in the token">
<span class="backup-card-hint">Only set this if the token's name would collide with an existing peer here.</span>
</div>
</div>
<p class="backup-card-hint" style="margin-top:8px">
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.
</p>
`;
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 = `<p class="backup-card-hint">Fetching app list…</p>`;
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 = `<p style="color:var(--danger,#dc2626)">No app list — peer unreachable or returned malformed JSON. See task log.</p>`;
return;
}
if (!parsed.apps.length) {
zone.innerHTML = `<p class="backup-card-hint">${this.escape(peerName)} has no apps installed.</p>`;
return;
}
zone.innerHTML = `
<div style="display:grid; grid-template-columns:repeat(auto-fill, minmax(220px, 1fr)); gap:6px">
${parsed.apps.map(a => `
<div style="display:flex; justify-content:space-between; align-items:center; padding:6px 10px; background:var(--surface-2, #1a1a1a); border-radius:6px">
<div>
<strong>${this.escape(a.slug)}</strong>
<span class="backup-card-hint" style="font-size:.78em; display:block">${this.formatBytes((a.size_kb || 0) * 1024)}</span>
</div>
<button class="backup-primary-btn" data-action="peer-pull" data-peer="${this.escape(peerName)}" data-app="${this.escape(a.slug)}">Pull</button>
</div>
`).join('')}
</div>
`;
}
openPullModal(peer, app) {
const modal = document.getElementById('peers-pull-modal');
const body = document.getElementById('peers-pull-modal-body');
if (!modal || !body) return;
body.innerHTML = `
<p>Pull <strong>${this.escape(app)}</strong> from peer <strong>${this.escape(peer)}</strong>?</p>
<p class="backup-card-hint">Streams the app's live data over SSH (via the peer-shell forced-command) and replaces this host's copy.</p>
<div style="margin-top:14px; display:flex; flex-direction:column; gap:8px">
<label style="display:flex; align-items:flex-start; gap:8px; cursor:pointer">
<input type="checkbox" id="peer-pull-opt-pre-backup" checked>
<span>Back up the destination's existing copy first
<span class="backup-card-hint" style="display:block; font-size:.85em">Safety net snapshots ${this.escape(app)} into your first enabled backup location, tagged <code>pre-migrate</code>.</span>
</span>
</label>
<label style="display:flex; align-items:flex-start; gap:8px; cursor:pointer">
<input type="checkbox" id="peer-pull-opt-rewrite" checked>
<span>Rewrite host-bound URLs to this host
<span class="backup-card-hint" style="display:block; font-size:.85em">Replaces CFG_*_URL / *_DOMAIN / *_HOSTNAME with this host's values after the transfer.</span>
</span>
</label>
</div>
`;
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) { summariseConfig(kind, cfg) {
if (kind === 'backup-channel') { if (kind === 'backup-channel') {
const host = cfg.hostname ? `hostname=<code>${this.escape(cfg.hostname)}</code>` : '<em>no hostname set</em>'; const host = cfg.hostname ? `hostname=<code>${this.escape(cfg.hostname)}</code>` : '<em>no hostname set</em>';

View File

@ -37,6 +37,25 @@ cliHandlePeerCommands()
peerCheckReachable "$arg1" peerCheckReachable "$arg1"
fi fi
;; ;;
token)
# Emit this host's pairing token. User shares it OOB with the peer.
peerPairingToken
;;
pair)
# peer pair <token> [override-name]
[[ -z "$arg1" ]] && { isNotice "Usage: peer pair <token> [override-name]"; return; }
peerPairingAccept "$arg1" "$arg2"
;;
pull)
# peer pull <peer> <app> [--no-pre-backup] [--keep-urls]
[[ -z "$arg1" || -z "$arg2" ]] && { isNotice "Usage: peer pull <peer> <app> [--no-pre-backup] [--keep-urls]"; return; }
peerPullApp "$arg1" "$arg2" "$arg3" "$arg4"
;;
apps)
# peer apps <peer> — live list of apps the peer has (calls peer-shell list-apps)
[[ -z "$arg1" ]] && { isNotice "Usage: peer apps <peer>"; return; }
peerListAppsRemote "$arg1"
;;
*) *)
isNotice "Invalid peer action: $action" isNotice "Invalid peer action: $action"
cliShowPeerHelp cliShowPeerHelp

View File

@ -23,7 +23,27 @@ cliShowPeerHelp()
echo " Reachability probe. With <name>, checks one; without, all. Updates" echo " Reachability probe. With <name>, checks one; without, all. Updates"
echo " the peer's status + last_seen columns." echo " the peer's status + last_seen columns."
echo "" 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 <token> [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 <peer>"
echo " Live list of apps the peer has installed (via peer-shell)."
echo ""
echo "peer pull <peer> <app> [--no-pre-backup] [--keep-urls]"
echo " Pull <app>'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 "Notes:"
echo " • Today only kind=backup-channel works. direct-ssh-direct and" echo " • kind=backup-channel (Phase 1/2) and kind=direct-ssh-direct"
echo " direct-ssh-via-relay (Connect blind-relay) ship with Phase 3." echo " (Phase 3) are live. kind=direct-ssh-via-relay (Connect blind"
echo " relay) waits for Connect itself to ship."
} }

View File

@ -13,6 +13,13 @@ peerAdd()
{ {
local name="$1"; shift local name="$1"; shift
local kind="$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") local nv; nv=$(peerValidateName "$name")
if [[ "$nv" != "ok" ]]; then if [[ "$nv" != "ok" ]]; then

View File

@ -54,8 +54,20 @@ peerCheckReachable()
fi fi
fi fi
;; ;;
direct-ssh-direct|direct-ssh-via-relay) direct-ssh-direct)
new_status="not-yet-implemented" # 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" new_status="unknown-kind"

View File

@ -33,16 +33,15 @@ peerValidateName()
echo "ok" echo "ok"
} }
# Validate kind. Phase 2 only allows 'backup-channel'; the others are accepted # Validate kind. backup-channel (Phase 1/2) and direct-ssh-direct (Phase 3)
# at the schema level but the bash helpers reject them until Phase 3 ships # are live; direct-ssh-via-relay needs Connect to exist (Phase 3b).
# their support to avoid users adding peers that nothing knows how to use.
peerValidateKind() peerValidateKind()
{ {
local kind="$1" local kind="$1"
case "$kind" in case "$kind" in
backup-channel) echo "ok" ;; backup-channel|direct-ssh-direct) echo "ok" ;;
direct-ssh-direct|direct-ssh-via-relay) direct-ssh-via-relay)
echo "not-yet-implemented"; return 1 ;; echo "needs-connect"; return 1 ;;
*) echo "unknown-kind"; return 1 ;; *) echo "unknown-kind"; return 1 ;;
esac esac
} }

View File

@ -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"
}

55
scripts/peer/peer_key.sh Normal file
View File

@ -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}'
}

View File

@ -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|<name>|<user>|<host>|<port>|<pubkey-b64>|<fingerprint>
#
# 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
}

124
scripts/peer/peer_pull.sh Normal file
View File

@ -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 <slug>` over SSH and untar into
# containers_dir/.
# 5. Reuse the install-time tag pipeline to repoint host-bound values:
# - dockerComposeUpdateAndStartApp <app> 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 <peer-name> <app-slug> [--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"
}

View File

@ -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: "<user>@<host> <port>" 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 <peer-name> <verb> [args...]
# Runs `peer-shell <verb> <args>` 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
}

106
scripts/peer/peer_shell.sh Normal file
View File

@ -0,0 +1,106 @@
#!/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/}"
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

View File

@ -7,7 +7,12 @@ peer_scripts=(
"peer/peer_add.sh" "peer/peer_add.sh"
"peer/peer_check.sh" "peer/peer_check.sh"
"peer/peer_helpers.sh" "peer/peer_helpers.sh"
"peer/peer_install_shell.sh"
"peer/peer_key.sh"
"peer/peer_list.sh" "peer/peer_list.sh"
"peer/peer_pairing.sh"
"peer/peer_pull.sh"
"peer/peer_remote.sh"
"peer/peer_remove.sh" "peer/peer_remove.sh"
) )

View File

@ -110,6 +110,19 @@ EOF
;; ;;
esac esac
fi 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 # Escape quotes and add to array
echo " \"$rel_path\"" >> "$array_file" echo " \"$rel_path\"" >> "$array_file"