From 1014dd6e4207ffc1815d87b08b400bae3bffeb25 Mon Sep 17 00:00:00 2001 From: librelad Date: Tue, 26 May 2026 17:43:56 +0100 Subject: [PATCH] feat(peers): introduce 'Peer' as a first-class concept (Phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A peer is a named reference to another LibrePortal instance. Phase 2 only implements kind=backup-channel (friendly label over a hostname that shows up in a shared backup repo); direct-ssh-direct and direct-ssh-via-relay (Connect's blind-relay) are reserved enum values for Phase 3. DB schema (db_create_tables.sh): CREATE TABLE peers ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL, kind TEXT NOT NULL DEFAULT 'backup-channel', config_json TEXT NOT NULL DEFAULT '{}', status TEXT DEFAULT 'unknown', last_seen TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP ); + indexes on name and kind. config_json is kind-specific so new transports don't need a schema migration. For backup-channel it carries {"hostname":"","loc_idx":N}. Bash module (scripts/peer/): peer_helpers.sh _peerDb, peerSqlEscape, peerValidateName/Kind. peer_add.sh peerAdd [k=v ...] → INSERT, refresh generator. Rejects unimplemented kinds early so users don't create dead-end peer records. peer_remove.sh peerRemove → DELETE. peer_list.sh peerList → JSON array; peerGet, peerNameForHostname (reverse-lookup for the migrate-tab overlay). peer_check.sh peerCheckReachable, peerCheckAll. For backup-channel 'reachable' = at least one snapshot from that hostname visible in (preferred|any enabled) location. Updates status + last_seen so UI dots render without re-probing. CLI (scripts/cli/commands/peer/): libreportal peer list libreportal peer get libreportal peer add backup-channel hostname= [loc_idx=] libreportal peer remove libreportal peer check [name] Auto-routed by cli_initialize.sh's category-discovery. WebUI data generator (scripts/webui/data/generators/peers/webui_peers.sh): Emits data/peers/generated/peers.json with the peerList output and a generated_at envelope. Hooked into webuiLibrePortalUpdate alongside the backup generators. Frontend: - New top-level /peers route in spa.js (PeersPage class, peers-content.html). - 'Peers' nav item in the topbar between Backups and the right-side controls. - Add-peer modal with friendly-name + kind + hostname + preferred-location selector (populated from the existing backup-locations data). - Per-peer card with status dot, last-checked time, Check + Remove buttons. - Phase 3 kinds appear in the kind dropdown as disabled options so users can see what's coming. Source-array wiring: - generate_arrays.sh auto-created files_peer.sh from the new peer/ dir. - cli_files.sh + app_files.sh include ${peer_scripts[@]} alphabetically. - files_webui.sh auto-picked-up the new peers/ generator subfolder. The migrate-tab friendly-name overlay (use peer names in /backup/migrate when a peer record exists for a hostname) is intentionally deferred — it's a 5-line frontend lookup once peers.json is loaded; cleaner to add after Phase 3 ships its peer-detail view. Signed-off-by: librelad --- .../frontend/html/peers-content.html | 64 +++++ .../libreportal/frontend/html/topbar.html | 11 + containers/libreportal/frontend/index.html | 1 + .../js/components/peers/peers-page.js | 254 ++++++++++++++++++ containers/libreportal/frontend/js/spa.js | 18 ++ .../cli/commands/peer/cli_peer_commands.sh | 45 ++++ scripts/cli/commands/peer/cli_peer_header.sh | 29 ++ scripts/database/tables/db_create_tables.sh | 24 ++ scripts/peer/peer_add.sh | 70 +++++ scripts/peer/peer_check.sh | 82 ++++++ scripts/peer/peer_helpers.sh | 48 ++++ scripts/peer/peer_list.sh | 63 +++++ scripts/peer/peer_remove.sh | 27 ++ scripts/source/files/app_files.sh | 1 + scripts/source/files/arrays/files_cli.sh | 2 + scripts/source/files/arrays/files_peer.sh | 13 + scripts/source/files/arrays/files_source.sh | 1 + scripts/source/files/arrays/files_webui.sh | 1 + scripts/source/files/cli_files.sh | 1 + .../data/generators/peers/webui_peers.sh | 31 +++ scripts/webui/webui_updater.sh | 6 + 21 files changed, 792 insertions(+) create mode 100644 containers/libreportal/frontend/html/peers-content.html create mode 100644 containers/libreportal/frontend/js/components/peers/peers-page.js create mode 100644 scripts/cli/commands/peer/cli_peer_commands.sh create mode 100644 scripts/cli/commands/peer/cli_peer_header.sh create mode 100644 scripts/peer/peer_add.sh create mode 100644 scripts/peer/peer_check.sh create mode 100644 scripts/peer/peer_helpers.sh create mode 100644 scripts/peer/peer_list.sh create mode 100644 scripts/peer/peer_remove.sh create mode 100644 scripts/source/files/arrays/files_peer.sh create mode 100644 scripts/webui/data/generators/peers/webui_peers.sh diff --git a/containers/libreportal/frontend/html/peers-content.html b/containers/libreportal/frontend/html/peers-content.html new file mode 100644 index 0000000..ec50ef5 --- /dev/null +++ b/containers/libreportal/frontend/html/peers-content.html @@ -0,0 +1,64 @@ +
+
+
+
+ + + + +
+
+
+
+
+ +
+
+
+

Add a peer

+ +
+
+ +
+
diff --git a/containers/libreportal/frontend/html/topbar.html b/containers/libreportal/frontend/html/topbar.html index a5ef5ae..b5b733a 100755 --- a/containers/libreportal/frontend/html/topbar.html +++ b/containers/libreportal/frontend/html/topbar.html @@ -51,6 +51,17 @@ Backups + + + + + + + + + + Peers +
diff --git a/containers/libreportal/frontend/index.html b/containers/libreportal/frontend/index.html index 9fb7f4e..9948151 100755 --- a/containers/libreportal/frontend/index.html +++ b/containers/libreportal/frontend/index.html @@ -100,6 +100,7 @@ + diff --git a/containers/libreportal/frontend/js/components/peers/peers-page.js b/containers/libreportal/frontend/js/components/peers/peers-page.js new file mode 100644 index 0000000..5024caa --- /dev/null +++ b/containers/libreportal/frontend/js/components/peers/peers-page.js @@ -0,0 +1,254 @@ +// Peers page controller. List + add + remove peer records. The data behind +// it is generated by scripts/webui/data/generators/peers/webui_peers.sh and +// served at /data/peers/generated/peers.json — Phase 2 only knows about +// kind=backup-channel; the other kinds light up in Phase 3. + +class PeersPage { + constructor() { + this.peers = []; + this.backupLocations = []; // populated for the loc_idx dropdown + this.taskManager = (typeof TaskManager !== 'undefined') ? new TaskManager() : null; + this.eventBound = false; + } + + async init() { + this.bindEvents(); + await this.refreshAll(); + this.render(); + } + + async refreshAll() { + const ts = Date.now(); + const [peersData, backupLocs] = await Promise.all([ + this.fetchJson(`/data/peers/generated/peers.json?t=${ts}`), + this.fetchJson(`/data/backup/generated/locations.json?t=${ts}`) + ]); + this.peers = peersData?.peers || []; + this.backupLocations = (backupLocs?.locations || []).filter(l => l.enabled); + } + + async fetchJson(url) { + try { const r = await fetch(url); if (!r.ok) return null; return await r.json(); } + catch { return null; } + } + + bindEvents() { + if (this.eventBound) return; + this.eventBound = true; + + document.addEventListener('click', (e) => { + if (e.target.closest('#peers-refresh-btn')) { + this.runTask('libreportal regen webui --force', 'webui', null); + 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; + } + const removeBtn = e.target.closest('[data-action="peer-remove"]'); + 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; + } + if (e.target.closest('[data-close-modal]') || e.target.matches('.backup-modal')) { + this.closeAllModals(); + return; + } + }); + } + + render() { + const list = document.getElementById('peers-list'); + const empty = document.getElementById('peers-empty'); + if (!list || !empty) return; + + if (!this.peers.length) { + list.innerHTML = ''; + empty.hidden = false; + return; + } + empty.hidden = true; + + list.innerHTML = this.peers.map(p => this.renderPeerCard(p)).join(''); + } + + renderPeerCard(peer) { + const cfg = peer.config || {}; + const cfgSummary = this.summariseConfig(peer.kind, cfg); + const statusClass = { + ok: 'ok', + 'no-snapshots': 'warn', + 'config-error': 'fail', + 'not-yet-implemented': 'warn', + 'unknown-kind': 'fail', + unknown: 'none' + }[peer.status] || 'none'; + const statusLabel = { + ok: 'Reachable', + 'no-snapshots': 'No recent snapshots', + 'config-error': 'Config error', + 'not-yet-implemented': 'Not yet supported', + 'unknown-kind': 'Unknown kind', + unknown: 'Not checked' + }[peer.status] || peer.status; + + return ` +
+
+
+
+ ${this.escape(peer.name)} + (${this.escape(peer.kind)}) + + ${this.escape(statusLabel)} +
+
${cfgSummary}
+ ${peer.last_seen ? `
Checked ${this.escape(this.formatRelativeTime(peer.last_seen))}
` : ''} +
+
+ + +
+
+
+ `; + } + + summariseConfig(kind, cfg) { + if (kind === 'backup-channel') { + const host = cfg.hostname ? `hostname=${this.escape(cfg.hostname)}` : 'no hostname set'; + const loc = cfg.loc_idx != null ? `location ${this.escape(String(cfg.loc_idx))}` : 'any enabled location'; + return `${host} · ${loc}`; + } + // direct-ssh kinds: minimal placeholder until Phase 3. + return Object.keys(cfg).length + ? Object.entries(cfg).map(([k, v]) => `${this.escape(k)}=${this.escape(String(v))}`).join(' · ') + : '(no config)'; + } + + openAddModal() { + const modal = document.getElementById('peers-add-modal'); + const body = document.getElementById('peers-add-modal-body'); + if (!modal || !body) return; + + const locOptions = [''] + .concat(this.backupLocations.map(l => + ``)) + .join(''); + + body.innerHTML = ` +
+
+ + + Used as the label everywhere — letters, digits, ._- only. +
+
+ + +
+
+ + + Match the source LibrePortal's CFG_INSTALL_NAME (or hostname if that's unset). +
+
+ + + Pinning a location skips probing the others for snapshots. +
+
+ `; + modal.classList.add('open'); + } + + async confirmAdd() { + const modal = document.getElementById('peers-add-modal'); + if (!modal) return; + const name = modal.querySelector('#peer-add-name')?.value?.trim(); + const kind = modal.querySelector('#peer-add-kind')?.value || 'backup-channel'; + const host = modal.querySelector('#peer-add-hostname')?.value?.trim(); + const loc = modal.querySelector('#peer-add-loc')?.value; + + if (!name) { this.notify('Name is required.', 'error'); return; } + if (!host) { this.notify('Hostname is required for backup-channel peers.', 'error'); return; } + if (!/^[A-Za-z0-9._-]+$/.test(name)) { + this.notify('Name must use letters, digits, dot, underscore or dash.', 'error'); + return; + } + + this.closeAllModals(); + const cfgPairs = [`hostname=${host}`]; + if (loc) cfgPairs.push(`loc_idx=${loc}`); + const cmd = `libreportal peer add ${name} ${kind} ${cfgPairs.join(' ')}`; + await this.runTask(cmd, 'peer', null); + setTimeout(() => this.refreshAll().then(() => this.render()), 1500); + } + + async removePeer(name) { + if (!confirm(`Remove peer "${name}"?\n\nThis only removes the local label — backups and the other host are untouched.`)) return; + await this.runTask(`libreportal peer remove ${name}`, 'peer', null); + setTimeout(() => this.refreshAll().then(() => this.render()), 1500); + } + + async checkPeer(name) { + await this.runTask(`libreportal peer check ${name}`, 'peer', null); + setTimeout(() => this.refreshAll().then(() => this.render()), 2000); + } + + closeAllModals() { + document.querySelectorAll('.backup-modal.open').forEach(m => m.classList.remove('open')); + } + + async runTask(command, type, app) { + if (!this.taskManager) { + this.notify('Task system unavailable', 'error'); + return; + } + try { + await this.taskManager.createTask(command, type, app); + } catch (err) { + this.notify(`Failed to queue task: ${err.message || err}`, 'error'); + } + } + + notify(message, kind) { + if (typeof window.showNotification === 'function') window.showNotification(message, kind); + else if (kind === 'error') console.error(message); + else console.log(message); + } + + formatRelativeTime(iso) { + if (!iso) return 'never'; + const t = Date.parse(iso); + if (!t) return iso; + const diff = Date.now() - t; + const minute = 60_000, hour = 60 * minute, day = 24 * hour; + if (diff < hour) return `${Math.max(1, Math.round(diff / minute))} min ago`; + if (diff < day) return `${Math.round(diff / hour)} h ago`; + if (diff < 7 * day) return `${Math.round(diff / day)} d ago`; + return new Date(t).toISOString().slice(0, 10); + } + + escape(s) { + return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' + }[c])); + } +} + +window.PeersPage = PeersPage; diff --git a/containers/libreportal/frontend/js/spa.js b/containers/libreportal/frontend/js/spa.js index 2124d8a..7e00169 100755 --- a/containers/libreportal/frontend/js/spa.js +++ b/containers/libreportal/frontend/js/spa.js @@ -77,6 +77,8 @@ class LibrePortalSPAClean { this.routes.set('/tasks*', () => this.handleTasks()); // Handle /tasks with query this.routes.set('/backup', () => this.handleBackup()); this.routes.set('/backup*', () => this.handleBackup()); + this.routes.set('/peers', () => this.handlePeers()); + this.routes.set('/peers*', () => this.handlePeers()); this.routes.set('/ssh', () => this.handleSsh()); // legacy → /admin/tools/ssh-access this.routes.set('/ssh*', () => this.handleSsh()); @@ -272,6 +274,22 @@ class LibrePortalSPAClean { } } + async handlePeers() { + try { + const html = await this.fetchContent('/html/peers-content.html'); + this.loadContent(html, 'Peers'); + if (typeof PeersPage !== 'undefined') { + window.peersPage = new PeersPage(); + await window.peersPage.init(); + } else { + console.error('PeersPage class not loaded'); + } + } catch (error) { + console.error('❌ Peers page load error:', error); + this.showError('Failed to load peers page'); + } + } + async handleSsh() { // Legacy /ssh → SSH Access under the Admin area. this.navigate('/admin/tools/ssh-access', true); diff --git a/scripts/cli/commands/peer/cli_peer_commands.sh b/scripts/cli/commands/peer/cli_peer_commands.sh new file mode 100644 index 0000000..bfe624c --- /dev/null +++ b/scripts/cli/commands/peer/cli_peer_commands.sh @@ -0,0 +1,45 @@ +#!/bin/bash + +cliHandlePeerCommands() +{ + local action="$initial_command2" + local arg1="$initial_command3" + local arg2="$initial_command4" + local arg3="$initial_command5" + local arg4="$initial_command6" + + case "$action" in + ""|help) + cliShowPeerHelp + ;; + list) + peerList + ;; + get) + [[ -z "$arg1" ]] && { isNotice "Usage: peer get "; return; } + peerGet "$arg1" + ;; + add) + # peer add [k=v] [k=v] + # Up to two k=v pairs from initial_command5..6 — covers backup-channel's + # hostname + loc_idx, which is the only kind that's wired today. + [[ -z "$arg1" || -z "$arg2" ]] && { isNotice "Usage: peer add [key=value ...]"; return; } + peerAdd "$arg1" "$arg2" "$arg3" "$arg4" + ;; + remove|rm|delete) + [[ -z "$arg1" ]] && { isNotice "Usage: peer remove "; return; } + peerRemove "$arg1" + ;; + check) + if [[ -z "$arg1" ]]; then + peerCheckAll + else + peerCheckReachable "$arg1" + fi + ;; + *) + isNotice "Invalid peer action: $action" + cliShowPeerHelp + ;; + esac +} diff --git a/scripts/cli/commands/peer/cli_peer_header.sh b/scripts/cli/commands/peer/cli_peer_header.sh new file mode 100644 index 0000000..36d8a63 --- /dev/null +++ b/scripts/cli/commands/peer/cli_peer_header.sh @@ -0,0 +1,29 @@ +#!/bin/bash + +cliShowPeerHelp() +{ + isHeader "LibrePortal Peer Commands" + echo "Manage named references to other LibrePortal instances." + echo "" + echo "peer list" + echo " JSON dump of every peer record (id, kind, config, status)." + echo "" + echo "peer get " + echo " JSON for a single peer." + echo "" + echo "peer add backup-channel hostname= [loc_idx=]" + echo " Add a friendly label for another LibrePortal whose backups land in" + echo " a location this host can see. loc_idx defaults to 'any enabled'." + echo "" + echo "peer remove " + echo " Delete the local peer record. Doesn't touch the other host or any" + echo " backups — just removes the label." + echo "" + echo "peer check [name]" + echo " Reachability probe. With , checks one; without, all. Updates" + echo " the peer's status + last_seen columns." + 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." +} diff --git a/scripts/database/tables/db_create_tables.sh b/scripts/database/tables/db_create_tables.sh index e29d676..27506e8 100755 --- a/scripts/database/tables/db_create_tables.sh +++ b/scripts/database/tables/db_create_tables.sh @@ -67,6 +67,30 @@ databaseCreateTables() checkSuccess "Creating $setup_table_name table" fi + setup_table_name=peers + if ! sqlite3 "$docker_dir/$db_file" ".tables" | grep -q "\b$setup_table_name\b"; then + # Named other LibrePortal instances. kind selects the transport: + # backup-channel Phase 1/2 — friendly label over a hostname + # that already shows up in a shared backup repo + # direct-ssh-direct Phase 3 — reachable peer over plain SSH + # direct-ssh-via-relay Phase 3b — peer over Connect's blind relay + # config_json carries kind-specific knobs (hostname, loc_idx, pubkey + # fingerprint, relay token, etc.) so adding new kinds doesn't need + # another schema migration. + local result=$(sqlite3 $docker_dir/$db_file "CREATE TABLE IF NOT EXISTS $setup_table_name ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + kind TEXT NOT NULL DEFAULT 'backup-channel', + config_json TEXT NOT NULL DEFAULT '{}', + status TEXT DEFAULT 'unknown', + last_seen TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP + );") + checkSuccess "Creating $setup_table_name table" + local result=$(sqlite3 "$docker_dir/$db_file" "CREATE INDEX IF NOT EXISTS idx_peers_name ON peers(name);") + local result=$(sqlite3 "$docker_dir/$db_file" "CREATE INDEX IF NOT EXISTS idx_peers_kind ON peers(kind);") + fi + setup_table_name=network_resources if ! sqlite3 "$docker_dir/$db_file" ".tables" | grep -q "\b$setup_table_name\b"; then # Simple unified network resources table - replaces all complex network tables diff --git a/scripts/peer/peer_add.sh b/scripts/peer/peer_add.sh new file mode 100644 index 0000000..a30f12b --- /dev/null +++ b/scripts/peer/peer_add.sh @@ -0,0 +1,70 @@ +#!/bin/bash + +# Add a peer record. Caller provides name + kind + a key=value config blob +# that's serialised into the config_json column. +# +# Usage: +# peerAdd [key1=val1] [key2=val2] ... +# +# Example (backup-channel — Phase 1/2): +# peerAdd homelab backup-channel hostname=homelab.local loc_idx=1 + +peerAdd() +{ + local name="$1"; shift + local kind="$1"; shift + + local nv; nv=$(peerValidateName "$name") + if [[ "$nv" != "ok" ]]; then + isError "Invalid peer name: $nv" + return 1 + fi + local kv; kv=$(peerValidateKind "$kind") + if [[ "$kv" != "ok" ]]; then + isError "Cannot use kind '$kind' yet: $kv" + return 1 + fi + + # Already exists? + local existing + existing=$(sqlite3 "$(_peerDb)" "SELECT id FROM peers WHERE name='$(peerSqlEscape "$name")';" 2>/dev/null) + if [[ -n "$existing" ]]; then + isError "Peer '$name' already exists (id=$existing). Use 'peer update' or 'peer remove' first." + return 1 + fi + + # Build the JSON blob from key=value args. We don't pull in jq; values are + # JSON-escaped inline (only handles strings and bare numerics, which is + # what the kind-specific schemas need today). + local first=1 + local cfg='{' + local kv_pair k v + for kv_pair in "$@"; do + k="${kv_pair%%=*}" + v="${kv_pair#*=}" + [[ -z "$k" || "$k" == "$kv_pair" ]] && continue # not a key=value + local rendered + if [[ "$v" =~ ^-?[0-9]+(\.[0-9]+)?$ ]]; then + rendered="$v" + else + local esc="${v//\\/\\\\}"; esc="${esc//\"/\\\"}" + rendered="\"$esc\"" + fi + if (( first )); then cfg+="\"$k\":$rendered"; first=0 + else cfg+=",\"$k\":$rendered"; fi + done + cfg+='}' + + sqlite3 "$(_peerDb)" \ + "INSERT INTO peers (name, kind, config_json) VALUES ('$(peerSqlEscape "$name")', '$(peerSqlEscape "$kind")', '$(peerSqlEscape "$cfg")');" 2>/dev/null + + if [[ $? -eq 0 ]]; then + isSuccessful "Peer '$name' added (kind=$kind)" + # Refresh WebUI cache if the generator is loaded. + declare -F webuiGeneratePeers >/dev/null 2>&1 && webuiGeneratePeers >/dev/null 2>&1 || true + return 0 + else + isError "Failed to insert peer '$name'" + return 1 + fi +} diff --git a/scripts/peer/peer_check.sh b/scripts/peer/peer_check.sh new file mode 100644 index 0000000..b08d634 --- /dev/null +++ b/scripts/peer/peer_check.sh @@ -0,0 +1,82 @@ +#!/bin/bash + +# Reachability check for a peer. The meaning of "reachable" depends on kind: +# backup-channel At least one snapshot from this peer's hostname is +# visible in the configured location within the last +# 30 days (or ever, if it's just been added). +# direct-ssh-direct SSH connect + 'peer-shell ping' (Phase 3). +# direct-ssh-via-relay Open relay session + 'peer-shell ping' (Phase 3b). +# +# Updates the peer's status + last_seen columns on success/failure so the UI +# can render a colored dot without re-running the check on every page load. + +peerCheckReachable() +{ + local name="$1" + if [[ -z "$name" ]]; then isError "peerCheckReachable: name required"; return 1; fi + + local row + row=$(sqlite3 "$(_peerDb)" "SELECT id, kind, config_json FROM peers WHERE name='$(peerSqlEscape "$name")';" 2>/dev/null) + if [[ -z "$row" ]]; then + isError "No peer named '$name'" + return 1 + fi + + local id kind cfg + IFS='|' read -r id kind cfg <<< "$row" + + local new_status="unknown" + local now + now=$(date -Iseconds) + + case "$kind" in + backup-channel) + local hostname loc_idx + hostname=$(printf '%s' "$cfg" | grep -o '"hostname":"[^"]*"' | head -1 | cut -d'"' -f4) + loc_idx=$(printf '%s' "$cfg" | grep -o '"loc_idx":[0-9]*' | head -1 | cut -d':' -f2) + if [[ -z "$hostname" ]]; then + new_status="config-error" + elif [[ -z "$loc_idx" ]]; then + # No preferred location — try any enabled location. + local found="" + while IFS= read -r idx; do + [[ -z "$idx" ]] && continue + if engineSnapshotsJson "$idx" "" "$hostname" 2>/dev/null | grep -q '"short_id":'; then + found="$idx"; break + fi + done < <(resticEnabledLocations) + [[ -n "$found" ]] && new_status="ok" || new_status="no-snapshots" + else + if engineSnapshotsJson "$loc_idx" "" "$hostname" 2>/dev/null | grep -q '"short_id":'; then + new_status="ok" + else + new_status="no-snapshots" + fi + fi + ;; + direct-ssh-direct|direct-ssh-via-relay) + new_status="not-yet-implemented" + ;; + *) + new_status="unknown-kind" + ;; + esac + + sqlite3 "$(_peerDb)" \ + "UPDATE peers SET status='$(peerSqlEscape "$new_status")', last_seen='$now' WHERE id=$id;" 2>/dev/null + + echo "$new_status" + [[ "$new_status" == "ok" ]] +} + +# Check every peer; useful for the WebUI's "Refresh" button. +peerCheckAll() +{ + local name + while IFS= read -r name; do + [[ -z "$name" ]] && continue + local status + status=$(peerCheckReachable "$name") + isNotice " $name → $status" + done < <(sqlite3 "$(_peerDb)" "SELECT name FROM peers ORDER BY name;" 2>/dev/null) +} diff --git a/scripts/peer/peer_helpers.sh b/scripts/peer/peer_helpers.sh new file mode 100644 index 0000000..e9246a3 --- /dev/null +++ b/scripts/peer/peer_helpers.sh @@ -0,0 +1,48 @@ +#!/bin/bash + +# Shared helpers for the peer subsystem. Peers are named other LibrePortal +# instances; rows live in the sqlite `peers` table (db_create_tables.sh). +# +# kind enum: +# backup-channel Friendly label over a hostname that shows up in a +# shared backup repo. No new networking — Phase 1/2. +# direct-ssh-direct Reachable peer over plain SSH (Phase 3). +# direct-ssh-via-relay Peer over Connect's blind relay (Phase 3b). +# +# config_json is kind-specific. For backup-channel: +# {"hostname":"homelab","loc_idx":1} + +_peerDb() { echo "$docker_dir/$db_file"; } + +# Quote a value for SQLite (escape single quotes by doubling). Stdin in, +# stdout out. Caller wraps the result in their own single quotes. +peerSqlEscape() +{ + local s="$1" + printf "%s" "${s//\'/\'\'}" +} + +# Validate that a string is a reasonable peer-name (alnum, dash, underscore, +# dot; 1..64 chars). Echo "ok" or the rejection reason; caller checks for "ok". +peerValidateName() +{ + local name="$1" + if [[ -z "$name" ]]; then echo "empty"; return 1; fi + if [[ ${#name} -gt 64 ]]; then echo "too-long"; return 1; fi + if [[ ! "$name" =~ ^[A-Za-z0-9._-]+$ ]]; then echo "invalid-chars"; return 1; fi + 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. +peerValidateKind() +{ + local kind="$1" + case "$kind" in + backup-channel) echo "ok" ;; + direct-ssh-direct|direct-ssh-via-relay) + echo "not-yet-implemented"; return 1 ;; + *) echo "unknown-kind"; return 1 ;; + esac +} diff --git a/scripts/peer/peer_list.sh b/scripts/peer/peer_list.sh new file mode 100644 index 0000000..b143c72 --- /dev/null +++ b/scripts/peer/peer_list.sh @@ -0,0 +1,63 @@ +#!/bin/bash + +# List peers — JSON array, one row per peer. Used by the WebUI generator and +# the CLI's `libreportal peer list` command. Output is one line of JSON. + +peerList() +{ + local out='[' + local first=1 + local row + while IFS='|' read -r id name kind config_json status last_seen created_at; do + [[ -z "$id" ]] && continue + # Each field is sqlite-escaped (single-quote-doubled) and JSON-encoded + # on the way out — and config_json is already JSON so we paste it raw. + local name_e="${name//\\/\\\\}"; name_e="${name_e//\"/\\\"}" + local kind_e="${kind//\\/\\\\}"; kind_e="${kind_e//\"/\\\"}" + local status_e="${status//\\/\\\\}"; status_e="${status_e//\"/\\\"}" + local last_e="${last_seen//\\/\\\\}"; last_e="${last_e//\"/\\\"}" + local created_e="${created_at//\\/\\\\}"; created_e="${created_e//\"/\\\"}" + local cfg="${config_json:-{\}}" + + (( first )) || out+="," + first=0 + out+="{\"id\":$id,\"name\":\"$name_e\",\"kind\":\"$kind_e\",\"config\":$cfg,\"status\":\"$status_e\",\"last_seen\":\"$last_e\",\"created_at\":\"$created_e\"}" + done < <(sqlite3 "$(_peerDb)" "SELECT id, name, kind, config_json, COALESCE(status,''), COALESCE(last_seen,''), COALESCE(created_at,'') FROM peers ORDER BY name;" 2>/dev/null) + out+=']' + echo "$out" +} + +peerGet() +{ + local name="$1" + if [[ -z "$name" ]]; then echo "null"; return 1; fi + local row + row=$(sqlite3 "$(_peerDb)" "SELECT id, name, kind, config_json, COALESCE(status,''), COALESCE(last_seen,''), COALESCE(created_at,'') FROM peers WHERE name='$(peerSqlEscape "$name")';" 2>/dev/null) + [[ -z "$row" ]] && { echo "null"; return 1; } + + local id n k cfg s last created + IFS='|' read -r id n k cfg s last created <<< "$row" + local name_e="${n//\\/\\\\}"; name_e="${name_e//\"/\\\"}" + local kind_e="${k//\\/\\\\}"; kind_e="${kind_e//\"/\\\"}" + printf '{"id":%s,"name":"%s","kind":"%s","config":%s,"status":"%s","last_seen":"%s","created_at":"%s"}\n' \ + "$id" "$name_e" "$kind_e" "${cfg:-{\}}" "$s" "$last" "$created" +} + +# Lookup peer name by hostname. Walks the backup-channel peers, parses their +# config.hostname, returns the matching peer name (or empty). Cheap; small N. +peerNameForHostname() +{ + local hostname="$1" + [[ -z "$hostname" ]] && return 1 + local row + while IFS='|' read -r name cfg; do + [[ -z "$name" ]] && continue + local h + h=$(printf '%s' "$cfg" | grep -o '"hostname":"[^"]*"' | head -1 | cut -d'"' -f4) + if [[ "$h" == "$hostname" ]]; then + echo "$name" + return 0 + fi + done < <(sqlite3 "$(_peerDb)" "SELECT name, config_json FROM peers WHERE kind='backup-channel';" 2>/dev/null) + return 1 +} diff --git a/scripts/peer/peer_remove.sh b/scripts/peer/peer_remove.sh new file mode 100644 index 0000000..518f128 --- /dev/null +++ b/scripts/peer/peer_remove.sh @@ -0,0 +1,27 @@ +#!/bin/bash + +# Delete a peer by name. Doesn't touch backups, SSH keys, or any actual peer +# state on the other host — just removes the local label. + +peerRemove() +{ + local name="$1" + if [[ -z "$name" ]]; then isError "peerRemove: name required"; return 1; fi + + local existing + existing=$(sqlite3 "$(_peerDb)" "SELECT id FROM peers WHERE name='$(peerSqlEscape "$name")';" 2>/dev/null) + if [[ -z "$existing" ]]; then + isNotice "No peer named '$name'" + return 0 + fi + + sqlite3 "$(_peerDb)" "DELETE FROM peers WHERE name='$(peerSqlEscape "$name")';" 2>/dev/null + if [[ $? -eq 0 ]]; then + isSuccessful "Peer '$name' removed" + declare -F webuiGeneratePeers >/dev/null 2>&1 && webuiGeneratePeers >/dev/null 2>&1 || true + return 0 + else + isError "Failed to remove peer '$name'" + return 1 + fi +} diff --git a/scripts/source/files/app_files.sh b/scripts/source/files/app_files.sh index 107bc26..c2e3f5b 100755 --- a/scripts/source/files/app_files.sh +++ b/scripts/source/files/app_files.sh @@ -17,6 +17,7 @@ files_libreportal_app=( "${migrate_scripts[@]}" "${network_scripts[@]}" "${os_scripts[@]}" + "${peer_scripts[@]}" "${restore_scripts[@]}" "${setup_scripts[@]}" "${source_scripts[@]}" diff --git a/scripts/source/files/arrays/files_cli.sh b/scripts/source/files/arrays/files_cli.sh index 3df7b1f..7c8f44b 100755 --- a/scripts/source/files/arrays/files_cli.sh +++ b/scripts/source/files/arrays/files_cli.sh @@ -24,6 +24,8 @@ cli_scripts=( "cli/commands/install/cli_install_header.sh" "cli/commands/ip/cli_ip_commands.sh" "cli/commands/ip/cli_ip_header.sh" + "cli/commands/peer/cli_peer_commands.sh" + "cli/commands/peer/cli_peer_header.sh" "cli/commands/regen/cli_regen_commands.sh" "cli/commands/regen/cli_regen_header.sh" "cli/commands/reset/cli_reset_commands.sh" diff --git a/scripts/source/files/arrays/files_peer.sh b/scripts/source/files/arrays/files_peer.sh new file mode 100644 index 0000000..6e76d4a --- /dev/null +++ b/scripts/source/files/arrays/files_peer.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +# This file is auto-generated by generate_arrays.sh +# Do not edit manually - run './scripts/source/files/generate_arrays.sh run' to regenerate + +peer_scripts=( + "peer/peer_add.sh" + "peer/peer_check.sh" + "peer/peer_helpers.sh" + "peer/peer_list.sh" + "peer/peer_remove.sh" + +) diff --git a/scripts/source/files/arrays/files_source.sh b/scripts/source/files/arrays/files_source.sh index 97734ee..c7041e0 100755 --- a/scripts/source/files/arrays/files_source.sh +++ b/scripts/source/files/arrays/files_source.sh @@ -20,6 +20,7 @@ source_scripts=( "source/files/arrays/files_migrate.sh" "source/files/arrays/files_network.sh" "source/files/arrays/files_os.sh" + "source/files/arrays/files_peer.sh" "source/files/arrays/files_restore.sh" "source/files/arrays/files_setup.sh" "source/files/arrays/files_source.sh" diff --git a/scripts/source/files/arrays/files_webui.sh b/scripts/source/files/arrays/files_webui.sh index 96153d9..1324ddf 100755 --- a/scripts/source/files/arrays/files_webui.sh +++ b/scripts/source/files/arrays/files_webui.sh @@ -26,6 +26,7 @@ webui_scripts=( "webui/data/generators/config/webui_cli_config_set.sh" "webui/data/generators/config/webui_generate_configs.sh" "webui/data/generators/config/webui_update_config.sh" + "webui/data/generators/peers/webui_peers.sh" "webui/data/generators/system/webui_ssh_access.sh" "webui/data/generators/system/webui_system_disk.sh" "webui/data/generators/system/webui_system_info.sh" diff --git a/scripts/source/files/cli_files.sh b/scripts/source/files/cli_files.sh index fbcdfb2..b5c0e6e 100755 --- a/scripts/source/files/cli_files.sh +++ b/scripts/source/files/cli_files.sh @@ -17,6 +17,7 @@ files_libreportal_cli=( "${migrate_scripts[@]}" "${network_scripts[@]}" "${os_scripts[@]}" + "${peer_scripts[@]}" "${restore_scripts[@]}" "${setup_scripts[@]}" "${source_scripts[@]}" diff --git a/scripts/webui/data/generators/peers/webui_peers.sh b/scripts/webui/data/generators/peers/webui_peers.sh new file mode 100644 index 0000000..f4ac02c --- /dev/null +++ b/scripts/webui/data/generators/peers/webui_peers.sh @@ -0,0 +1,31 @@ +#!/bin/bash + +# Generate data/peers/generated/peers.json — drives the /peers WebUI page and +# is also read by the /backup/migrate tab to overlay friendly names on top of +# bare hostnames. +# +# This is just peerList wrapped with a generated_at envelope; no extra logic. + +webuiGeneratePeers() +{ + local output_dir="$containers_dir/libreportal/frontend/data/peers/generated" + local output_file="$output_dir/peers.json" + local temp_file="${output_file}.tmp.$$" + + runFileOp mkdir -p "$output_dir" + + local generated_at + generated_at=$(date -Iseconds) + local peers + peers=$(peerList 2>/dev/null) + [[ -z "$peers" ]] && peers='[]' + + cat > "$temp_file" </dev/null || true +} diff --git a/scripts/webui/webui_updater.sh b/scripts/webui/webui_updater.sh index 27b8135..785549b 100755 --- a/scripts/webui/webui_updater.sh +++ b/scripts/webui/webui_updater.sh @@ -89,6 +89,12 @@ webuiLibrePortalUpdate() { local result=$(webuiGenerateBackupLocations && webuiGenerateBackupDashboard && webuiGenerateBackupSnapshots all && webuiGenerateBackupAppStatus && webuiGenerateBackupEngines && webuiGenerateBackupSchema && webuiGenerateBackupPasswords && webuiGenerateBackupMigrate) checkSuccess "Refreshed backup dashboard data..." + # Peers (named other LibrePortal instances) — small, cheap; lives + # in its own data/peers/generated/peers.json file consumed by + # /peers and overlay-read by the migrate tab. + local result=$(webuiGeneratePeers) + checkSuccess "Refreshed peers data..." + # SSH access snapshot (authorized keys + password-login state) local result=$(webuiGenerateSshAccess) checkSuccess "Refreshed SSH access data..."