Merge claude/2

This commit is contained in:
librelad 2026-05-26 17:43:56 +01:00
commit b185862252
21 changed files with 792 additions and 0 deletions

View File

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

View File

@ -51,6 +51,17 @@
</svg>
Backups
</a>
<a href="/peers" class="nav-item" id="nav-peers">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="6" cy="7" r="3"></circle>
<circle cx="18" cy="7" r="3"></circle>
<path d="M9 17l3-3 3 3"></path>
<path d="M12 14v7"></path>
<path d="M6 10v3a3 3 0 003 3"></path>
<path d="M18 10v3a3 3 0 01-3 3"></path>
</svg>
Peers
</a>
</nav>
<div class="mobile-drawer-page-section" id="mobile-drawer-page-section"></div>
<div class="topbar-controls">

View File

@ -100,6 +100,7 @@
<script src="/js/components/backup/backup-page.js"></script>
<script src="/js/components/backup/backup-app-card.js"></script>
<script src="/js/components/ssh/ssh-page.js"></script>
<script src="/js/components/peers/peers-page.js"></script>
<script src="/js/components/admin/charts.js"></script>
<script src="/js/components/admin/admin-overview.js"></script>
<script src="/js/components/admin/admin-system.js"></script>

View File

@ -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 `
<div class="backup-location-row" style="margin-bottom:10px; padding:14px 18px; background:var(--surface-2, #1a1a1a); border-radius:8px">
<div style="display:flex; justify-content:space-between; align-items:flex-start; gap:14px">
<div style="min-width:0; flex:1">
<div style="display:flex; align-items:center; gap:10px; margin-bottom:4px">
<strong style="font-size:1.1em">${this.escape(peer.name)}</strong>
<span class="backup-card-hint" style="opacity:.7">(${this.escape(peer.kind)})</span>
<span class="backup-status-dot ${statusClass}" title="${this.escape(statusLabel)}"></span>
<span class="backup-card-hint" style="font-size:.8em">${this.escape(statusLabel)}</span>
</div>
<div class="backup-card-hint" style="font-size:.9em">${cfgSummary}</div>
${peer.last_seen ? `<div class="backup-card-hint" style="font-size:.78em; margin-top:4px">Checked ${this.escape(this.formatRelativeTime(peer.last_seen))}</div>` : ''}
</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>
<button class="backup-danger-btn" data-action="peer-remove" data-name="${this.escape(peer.name)}">Remove</button>
</div>
</div>
</div>
`;
}
summariseConfig(kind, cfg) {
if (kind === 'backup-channel') {
const host = cfg.hostname ? `hostname=<code>${this.escape(cfg.hostname)}</code>` : '<em>no hostname set</em>';
const loc = cfg.loc_idx != null ? `location <code>${this.escape(String(cfg.loc_idx))}</code>` : '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)}=<code>${this.escape(String(v))}</code>`).join(' · ')
: '<em>(no config)</em>';
}
openAddModal() {
const modal = document.getElementById('peers-add-modal');
const body = document.getElementById('peers-add-modal-body');
if (!modal || !body) return;
const locOptions = ['<option value="">Any enabled location</option>']
.concat(this.backupLocations.map(l =>
`<option value="${l.idx}">${this.escape(l.name || `Location ${l.idx}`)} (idx ${l.idx})</option>`))
.join('');
body.innerHTML = `
<div class="backup-form-grid">
<div>
<label for="peer-add-name">Name</label>
<input type="text" class="form-control" id="peer-add-name" placeholder="e.g. homelab">
<span class="backup-card-hint">Used as the label everywhere letters, digits, ._- only.</span>
</div>
<div>
<label for="peer-add-kind">Kind</label>
<select class="form-control" id="peer-add-kind">
<option value="backup-channel" selected>Backup channel (shared backup location)</option>
<option value="direct-ssh-direct" disabled>Direct SSH (Phase 3)</option>
<option value="direct-ssh-via-relay" disabled>Via Connect relay (Phase 3b)</option>
</select>
</div>
<div>
<label for="peer-add-hostname">Source hostname</label>
<input type="text" class="form-control" id="peer-add-hostname" placeholder="Hostname as it appears in restic snapshots">
<span class="backup-card-hint">Match the source LibrePortal's CFG_INSTALL_NAME (or hostname if that's unset).</span>
</div>
<div>
<label for="peer-add-loc">Preferred location</label>
<select class="form-control" id="peer-add-loc">${locOptions}</select>
<span class="backup-card-hint">Pinning a location skips probing the others for snapshots.</span>
</div>
</div>
`;
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 => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[c]));
}
}
window.PeersPage = PeersPage;

View File

@ -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);

View File

@ -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 <name>"; return; }
peerGet "$arg1"
;;
add)
# peer add <name> <kind> [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 <name> <kind> [key=value ...]"; return; }
peerAdd "$arg1" "$arg2" "$arg3" "$arg4"
;;
remove|rm|delete)
[[ -z "$arg1" ]] && { isNotice "Usage: peer remove <name>"; return; }
peerRemove "$arg1"
;;
check)
if [[ -z "$arg1" ]]; then
peerCheckAll
else
peerCheckReachable "$arg1"
fi
;;
*)
isNotice "Invalid peer action: $action"
cliShowPeerHelp
;;
esac
}

View File

@ -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 <name>"
echo " JSON for a single peer."
echo ""
echo "peer add <name> backup-channel hostname=<host> [loc_idx=<n>]"
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 <name>"
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 <name>, 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."
}

View File

@ -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

70
scripts/peer/peer_add.sh Normal file
View File

@ -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 <name> <kind> [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
}

View File

@ -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)
}

View File

@ -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
}

63
scripts/peer/peer_list.sh Normal file
View File

@ -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
}

View File

@ -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
}

View File

@ -17,6 +17,7 @@ files_libreportal_app=(
"${migrate_scripts[@]}"
"${network_scripts[@]}"
"${os_scripts[@]}"
"${peer_scripts[@]}"
"${restore_scripts[@]}"
"${setup_scripts[@]}"
"${source_scripts[@]}"

View File

@ -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"

View File

@ -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"
)

View File

@ -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"

View File

@ -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"

View File

@ -17,6 +17,7 @@ files_libreportal_cli=(
"${migrate_scripts[@]}"
"${network_scripts[@]}"
"${os_scripts[@]}"
"${peer_scripts[@]}"
"${restore_scripts[@]}"
"${setup_scripts[@]}"
"${source_scripts[@]}"

View File

@ -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" <<EOF
{
"generated_at": "$generated_at",
"peers": $peers
}
EOF
runFileOp mv "$temp_file" "$output_file"
runFileOp chmod 644 "$output_file" 2>/dev/null || true
}

View File

@ -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..."