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 @@
+
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))}
` : ''}
+
+
+ Check
+ Remove
+
+
+
+ `;
+ }
+
+ 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 = ['
Any enabled location ']
+ .concat(this.backupLocations.map(l =>
+ `
${this.escape(l.name || `Location ${l.idx}`)} (idx ${l.idx}) `))
+ .join('');
+
+ body.innerHTML = `
+
+ `;
+ 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..."