Merge claude/2
This commit is contained in:
commit
e9e29ba703
@ -25,12 +25,26 @@
|
||||
</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 peer
|
||||
Add backup-channel peer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -62,3 +76,44 @@
|
||||
</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);
|
||||
return;
|
||||
}
|
||||
if (e.target.closest('#peers-add-btn')) {
|
||||
this.openAddModal();
|
||||
return;
|
||||
}
|
||||
if (e.target.closest('#peers-add-confirm')) {
|
||||
this.confirmAdd();
|
||||
return;
|
||||
}
|
||||
if (e.target.closest('#peers-add-btn')) { this.openAddModal(); return; }
|
||||
if (e.target.closest('#peers-add-confirm')) { this.confirmAdd(); 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-pair-confirm')){ this.confirmPair(); return; }
|
||||
if (e.target.closest('#peers-pull-confirm')){ this.confirmPull(); return; }
|
||||
const removeBtn = e.target.closest('[data-action="peer-remove"]');
|
||||
if (removeBtn) {
|
||||
this.removePeer(removeBtn.dataset.name);
|
||||
if (removeBtn) { this.removePeer(removeBtn.dataset.name); return; }
|
||||
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;
|
||||
}
|
||||
const checkBtn = e.target.closest('[data-action="peer-check"]');
|
||||
if (checkBtn) {
|
||||
this.checkPeer(checkBtn.dataset.name);
|
||||
const appsBtn = e.target.closest('[data-action="peer-apps"]');
|
||||
if (appsBtn) {
|
||||
this.fetchAndShowPeerApps(appsBtn.dataset.name);
|
||||
return;
|
||||
}
|
||||
if (e.target.closest('[data-close-modal]') || e.target.matches('.backup-modal')) {
|
||||
@ -117,13 +119,207 @@ class PeersPage {
|
||||
</div>
|
||||
<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>
|
||||
${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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="peers-apps-zone" data-peer="${this.escape(peer.name)}" hidden style="margin-top:12px"></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) {
|
||||
if (kind === 'backup-channel') {
|
||||
const host = cfg.hostname ? `hostname=<code>${this.escape(cfg.hostname)}</code>` : '<em>no hostname set</em>';
|
||||
|
||||
@ -37,6 +37,25 @@ cliHandlePeerCommands()
|
||||
peerCheckReachable "$arg1"
|
||||
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"
|
||||
cliShowPeerHelp
|
||||
|
||||
@ -23,7 +23,27 @@ cliShowPeerHelp()
|
||||
echo " Reachability probe. With <name>, checks one; without, all. Updates"
|
||||
echo " the peer's status + last_seen columns."
|
||||
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 " • Today only kind=backup-channel works. direct-ssh-direct and"
|
||||
echo " direct-ssh-via-relay (Connect blind-relay) ship with Phase 3."
|
||||
echo " • kind=backup-channel (Phase 1/2) and kind=direct-ssh-direct"
|
||||
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 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")
|
||||
if [[ "$nv" != "ok" ]]; then
|
||||
|
||||
@ -54,8 +54,20 @@ peerCheckReachable()
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
direct-ssh-direct|direct-ssh-via-relay)
|
||||
new_status="not-yet-implemented"
|
||||
direct-ssh-direct)
|
||||
# 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"
|
||||
|
||||
@ -33,16 +33,15 @@ peerValidateName()
|
||||
echo "ok"
|
||||
}
|
||||
|
||||
# Validate kind. Phase 2 only allows 'backup-channel'; the others are accepted
|
||||
# at the schema level but the bash helpers reject them until Phase 3 ships
|
||||
# their support to avoid users adding peers that nothing knows how to use.
|
||||
# Validate kind. backup-channel (Phase 1/2) and direct-ssh-direct (Phase 3)
|
||||
# are live; direct-ssh-via-relay needs Connect to exist (Phase 3b).
|
||||
peerValidateKind()
|
||||
{
|
||||
local kind="$1"
|
||||
case "$kind" in
|
||||
backup-channel) echo "ok" ;;
|
||||
direct-ssh-direct|direct-ssh-via-relay)
|
||||
echo "not-yet-implemented"; return 1 ;;
|
||||
backup-channel|direct-ssh-direct) echo "ok" ;;
|
||||
direct-ssh-via-relay)
|
||||
echo "needs-connect"; return 1 ;;
|
||||
*) echo "unknown-kind"; return 1 ;;
|
||||
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_check.sh"
|
||||
"peer/peer_helpers.sh"
|
||||
"peer/peer_install_shell.sh"
|
||||
"peer/peer_key.sh"
|
||||
"peer/peer_list.sh"
|
||||
"peer/peer_pairing.sh"
|
||||
"peer/peer_pull.sh"
|
||||
"peer/peer_remote.sh"
|
||||
"peer/peer_remove.sh"
|
||||
|
||||
)
|
||||
|
||||
@ -110,6 +110,19 @@ EOF
|
||||
;;
|
||||
esac
|
||||
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
|
||||
echo " \"$rel_path\"" >> "$array_file"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user