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>
163 lines
5.9 KiB
Bash
163 lines
5.9 KiB
Bash
#!/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
|
|
}
|