End-to-end direct-ssh-direct: two LibrePortal instances exchange pairing
tokens, each authorizes the other to call a locked-down peer-shell dispatcher
via SSH forced-command, then either side can pull live app data from the
other without needing a shared backup repo.
Push and Connect-via-relay are deferred — push is symmetric to pull (same
forced-command, opposite verb), and the relay variant waits for Connect to
actually exist (config_json + kind enum already future-proofed in Phase 2).
Key generation (peer_key.sh):
One ed25519 keypair per install at ~<manager>/.ssh/libreportal-peer{,.pub}.
Generated lazily on the first peer-related call. Used as our outbound
SSH identity AND as the pubkey other instances authorize.
Forced-command dispatcher (peer_shell.sh):
Standalone script, deployed by peerInstallShell() to
~<manager>/.local/bin/peer-shell. authorized_keys entries look like:
command="~/.local/bin/peer-shell <peer-name>",no-pty,no-port-forwarding,
no-X11-forwarding,no-agent-forwarding,no-user-rc ssh-ed25519 AAAA… peer:<name>
sshd hands us $SSH_ORIGINAL_COMMAND; we parse, whitelist the verb, and
refuse anything else. Verbs:
ping Liveness probe (JSON ok:true).
list-apps JSON {peer, apps:[{slug, size_kb}]}.
stream-app tar of containers_dir/<slug> to stdout (slug strictly
validated — lowercase alnum+dash; rejects path traversal).
Audit log appended to ~/.local/state/libreportal/peer-shell.log. Excluded
from the generated source arrays (would crash any sourcing shell on empty
SSH_ORIGINAL_COMMAND); generate_arrays.sh skip-list extended.
Pairing token (peer_pairing.sh):
Format: lp-peer|v1|<name>|<user>|<host>|<port>|<base64-pubkey>|<fingerprint>
Pipe-delimited because the SHA256 fingerprint and base64 pubkey both
contain ':'. peerPairingParse decodes + re-derives the fingerprint from
the actual key, refusing tokens with mismatched fingerprints (catches
truncation / tampering). peerPairingAccept:
1. Installs peer-shell (peerInstallShell).
2. Appends to authorized_keys with the lockdown options above.
3. Inserts a peers row (kind=direct-ssh-direct, config carries host,
port, user, fingerprint).
Symmetric — user runs accept on BOTH sides with the other's token to
enable bidirectional calls.
Outbound SSH (peer_remote.sh):
peerExec <name> <verb> [args] — looks up the peer's connection config and
ssh's in with the right key, BatchMode + ConnectTimeout + accept-new for
the host key. peerPing wraps it and updates peers.status + last_seen.
Pull-an-app (peer_pull.sh):
peerPullApp <peer> <app> [--no-pre-backup] [--keep-urls]
1. peerPing (refuse if unreachable).
2. migratePreBackupDestination (reuses the Phase 0 safety wrapper —
same restic-tagged pre-migrate snapshot as the backup-channel flow).
3. Stop + wipe destination's app folder.
4. peerExec stream-app | tar -x (pipefail; bails on partial transfers).
5. migrateApplyUrlRewrite + dockerComposeUpdateAndStartApp install
(URL repointing, idempotent install path).
6. dockerComposeUp + post-restore hooks.
Identical Stage-2..6 to migrateApplyApp; only the data source differs
(tar-over-SSH instead of restic-restore).
CLI (cli_peer_commands.sh + header):
libreportal peer token — emit this host's pairing token
libreportal peer pair <token> [name] — accept a token (override name)
libreportal peer apps <peer> — live peer-shell list-apps
libreportal peer pull <peer> <app> [--no-pre-backup] [--keep-urls]
WebUI (/peers):
Header gains 'Show my token' and 'Pair with token' buttons (both open
modals around the matching CLI verbs). Token modal warns the user that
the token is credentials. Pair modal accepts a free-form override name.
Direct-SSH peer cards gain a 'List apps' button that opens an inline
drawer showing the peer's live app inventory (via peer apps) with per-
app 'Pull' buttons. Pull modal has the same two safety toggles as the
Migrate tab (pre-backup ON, URL rewrite ON by default).
Backup-channel manual-add modal kept; direct-SSH must use the token flow.
Smoke-tested:
- All 16 peer-subsystem functions register without crashing the shell.
- peer-shell ping ⇒ {ok:true}; unknown-verb refused; path-traversal slug
refused; valid-slug streams.
- Token emit→parse round-trip preserves every field; garbage rejected
with not-a-token; v99 rejected with unsupported-version.
Signed-off-by: librelad <librelad@digitalangels.vip>
125 lines
5.1 KiB
Bash
125 lines
5.1 KiB
Bash
#!/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"
|
|
}
|