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>
This commit is contained in:
parent
c0e01ae77d
commit
3fe2c0660a
@ -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>
|
||||||
|
|||||||
@ -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>';
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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."
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
41
scripts/peer/peer_install_shell.sh
Normal file
41
scripts/peer/peer_install_shell.sh
Normal 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
55
scripts/peer/peer_key.sh
Normal 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}'
|
||||||
|
}
|
||||||
162
scripts/peer/peer_pairing.sh
Normal file
162
scripts/peer/peer_pairing.sh
Normal 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
124
scripts/peer/peer_pull.sh
Normal 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"
|
||||||
|
}
|
||||||
82
scripts/peer/peer_remote.sh
Normal file
82
scripts/peer/peer_remote.sh
Normal 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
106
scripts/peer/peer_shell.sh
Normal 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
|
||||||
@ -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"
|
||||||
|
|
||||||
)
|
)
|
||||||
|
|||||||
@ -111,6 +111,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"
|
||||||
done
|
done
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user