librelad 3fe2c0660a feat(peers): direct peer SSH — pairing + peer-shell + pull (Phase 3)
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>
2026-05-26 17:56:57 +01:00

120 lines
5.2 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<div class="container peers-layout">
<div class="main">
<div class="peers-page" id="peers-page">
<div class="config-section">
<div class="page-header">
<div class="page-header-icon-slot">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="6" cy="7" r="3"></circle>
<circle cx="18" cy="7" r="3"></circle>
<path d="M9 17l3-3 3 3"></path>
<path d="M12 14v7"></path>
<path d="M6 10v3a3 3 0 003 3"></path>
<path d="M18 10v3a3 3 0 01-3 3"></path>
</svg>
</div>
<div class="page-header-title">
<h1>Peers</h1>
<p>Named references to other LibrePortal instances. Use them in the Migrate tab to pull apps across without typing hostnames.</p>
</div>
<div class="page-header-actions">
<button class="backup-refresh-btn" id="peers-refresh-btn" title="Refresh">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="23 4 23 10 17 10"></polyline>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path>
</svg>
Refresh
</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">
<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="5" y1="12" x2="19" y2="12"></line>
</svg>
Add backup-channel peer
</button>
</div>
</div>
<div class="peers-empty" id="peers-empty" hidden>
<p>No peers yet.</p>
<p class="backup-card-hint">
Add one to give a memorable name to another LibrePortal you share a backup location with.
Direct-SSH peers (no shared backup repo needed) ship with Phase 3.
</p>
</div>
<div class="peers-list" id="peers-list"></div>
</div>
</div>
</div>
</div>
<div class="backup-modal" id="peers-add-modal">
<div class="backup-modal-inner">
<div class="backup-modal-header">
<h3>Add a peer</h3>
<button class="backup-modal-close" data-close-modal>×</button>
</div>
<div class="backup-modal-body" id="peers-add-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-add-confirm">Add peer</button>
</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>