fix(lnd-ui,bitcoin-ui): OTA-breaking lnd-ui spec, 404 channels link, iframe copy, node URI

All four found by verifying on archi-dev-box rather than assuming.

container-specs.sh: archy-lnd-ui was specified as a BRIDGE container with
SPEC_PORTS="18083:80", but docker/lnd-ui/nginx.conf listens on 18083
directly (it must, to proxy the backend on 127.0.0.1:5678 same-origin).
Recreating from that spec publishes host 18083 to container port 80, where
nothing listens. Reproduced on the node: the app came back with :18083
refusing connections, HTTP 000. This never fired before because the running
containers are created by first-boot-containers.sh, which is host-networked
and never reads this file; the spec is only consulted when self-update.sh
rebuilds a UI image, and that only happens when a file under docker/lnd-ui/
changes — which is exactly what the previous two commits did. So the next
OTA would have taken lnd-ui down on every node. Now SPEC_NETWORK="host"
with no port mapping, matching what actually runs. NET_BIND_SERVICE dropped
with it: 18083 is unprivileged.

lnd-ui channels link: pointed at /apps/lnd/channels, but that route is a
CHILD of the /dashboard record in neode-ui's router, so the real path is
/dashboard/apps/lnd/channels. nginx's SPA fallback returns 200 for the
wrong path, so it failed as vue-router's NotFound view rather than an HTTP
404 — both the Payment Channels card and the Manage Channels button.

Both apps, copy buttons: navigator.clipboard only exists in a secure
context, and nodes serve these apps over plain http; the main UI also
embeds them in an iframe, where the async Clipboard API is separately gated
by the clipboard-write permission policy. Every copy button silently did
nothing there. Added an execCommand('copy') fallback behind a copyText()
helper and routed all six call sites through it.

lnd-ui Node ID: showed the bare pubkey whenever getinfo.uris was empty,
which is the common case — LND only populates uris once it is advertising
an external address. The bare pubkey is not what a peer pastes to open a
channel. The full pubkey@host:9735 URI is now built from the Tor onion
where available, falling back to this node's address, with a hint saying
which and what its reachability is. The QR encodes the URI too.

Verified on archi-dev-box: both images rebuilt and containers recreated
from the specs; lnd-ui and bitcoin-ui both serve 200 with the new assets;
and the RPCs the new tabs depend on all answer on the live node —
getblockstats returns every field the charts read, getpeerinfo returns 11
peers carrying relaytxes and network values the classifier handles.

Note for whoever tests bitcoin-ui's Insights/Peers tabs: /bitcoin-rpc/ now
sits behind auth_request /_session_check (a05956c4 et al), so it answers
401 to an unauthenticated curl by design. A logged-in browser sends the
session cookie same-origin, which is how those tabs get their data.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-02 19:33:21 -04:00
co-authored by Claude Opus 5
parent 5c9d5dc424
commit aaa89789d2
3 changed files with 115 additions and 22 deletions
+37 -5
View File
@@ -1171,6 +1171,38 @@
return body.result;
}
// Clipboard with a fallback. navigator.clipboard only exists in a secure
// context, and most nodes serve this app over plain http; on top of
// that, the main UI embeds these apps in an iframe, where the async
// Clipboard API is additionally gated by the clipboard-write permission
// policy. execCommand('copy') still works in both situations, so it is
// the fallback rather than letting the button silently do nothing.
function legacyCopy(text) {
return new Promise((resolve, reject) => {
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
ta.style.position = 'fixed';
ta.style.top = '0';
ta.style.left = '0';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
ta.setSelectionRange(0, ta.value.length);
const ok = document.execCommand('copy');
document.body.removeChild(ta);
if (ok) resolve(); else reject(new Error('copy rejected'));
} catch (e) { reject(e); }
});
}
function copyText(text) {
if (navigator.clipboard && window.isSecureContext) {
return navigator.clipboard.writeText(text).catch(() => legacyCopy(text));
}
return legacyCopy(text);
}
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>"']/g, char => ({
'&': '&amp;',
@@ -1652,11 +1684,11 @@
function copyRPCInfo() {
// No password here. It is a manifest-declared generated secret that
// the orchestrator renders straight into this app's nginx upstream;
// the browser never receives it. The old version of this function
// pasted a hardcoded "archipelago123", which was not the real
// credential and only ever misled whoever copied it.
// the browser never receives it. This function used to paste a
// hardcoded placeholder that was not the real credential and only
// ever misled whoever copied it.
const info = `RPC Host: ${window.location.hostname}:8332\nRPC User: archipelago\nRPC Password: (held in the node secret store)\nRPC Endpoint: ${RPC_ENDPOINT}`;
navigator.clipboard.writeText(info).then(() => {
copyText(info).then(() => {
alert('RPC info copied to clipboard!');
});
}
@@ -1986,7 +2018,7 @@ RPC server active on port 8332`;
function copyEl(id, btn) {
const text = document.getElementById(id)?.textContent.trim();
if (!text || text === '-') return;
navigator.clipboard.writeText(text).then(() => {
copyText(text).then(() => {
const orig = btn.innerHTML;
btn.innerHTML = '<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>';
btn.style.color = '#4ade80';
+66 -15
View File
@@ -968,10 +968,14 @@
return new URLSearchParams(window.location.search).get('backend') || '';
}
// The full channel-management UI is the Archipelago one at
// /apps/lnd/channels, served by the main UI on the standard web port —
// not this app's :18083. Drop the port to land on it.
const CHANNELS_URL = window.location.protocol + '//' + window.location.hostname + '/apps/lnd/channels';
// The full channel-management UI is the Archipelago one, served by the
// main UI on the standard web port — not this app's :18083, so the port
// is dropped. The /dashboard prefix matters: the route is a CHILD of
// the /dashboard record in neode-ui/src/router/index.ts, so bare
// /apps/lnd/channels is not a route at all. nginx's SPA fallback still
// returns 200 for it, so it fails as vue-router's NotFound view rather
// than an HTTP 404 — which is exactly how it presented on archi-dev-box.
const CHANNELS_URL = window.location.protocol + '//' + window.location.hostname + '/dashboard/apps/lnd/channels';
// ── State ───────────────────────────────────────────────────────
let unit = 'sats';
@@ -1033,6 +1037,38 @@
return String(s == null ? '' : s).replace(/[&<>"']/g, c =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
}
// Clipboard with a fallback. navigator.clipboard only exists in a secure
// context, and most nodes serve this app over plain http; on top of
// that, the main UI embeds these apps in an iframe, where the async
// Clipboard API is additionally gated by the clipboard-write permission
// policy. execCommand('copy') still works in both situations, so it is
// the fallback rather than letting the button silently do nothing.
function legacyCopy(text) {
return new Promise((resolve, reject) => {
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
ta.style.position = 'fixed';
ta.style.top = '0';
ta.style.left = '0';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
ta.setSelectionRange(0, ta.value.length);
const ok = document.execCommand('copy');
document.body.removeChild(ta);
if (ok) resolve(); else reject(new Error('copy rejected'));
} catch (e) { reject(e); }
});
}
function copyText(text) {
if (navigator.clipboard && window.isSecureContext) {
return navigator.clipboard.writeText(text).catch(() => legacyCopy(text));
}
return legacyCopy(text);
}
function setText(id, text) { const el = document.getElementById(id); if (el) el.textContent = text; }
function setHtml(id, html) { const el = document.getElementById(id); if (el) el.innerHTML = html; }
@@ -1480,18 +1516,33 @@
setText('cfgWumbo', reachable ? (has(19) || has(18) ? 'Enabled' : 'Not advertised') : '—');
// Node ID panel lives in Connect, but is fed by getinfo.
setText('nodePubkey', (g && g.identity_pubkey) || '—');
// The useful thing to share is the full pubkey@host:port URI — that
// is what a peer pastes to open a channel — not the bare pubkey.
// LND only populates getinfo.uris once it is advertising an
// external address, so when that list is empty we build the URI
// ourselves from the Tor onion (preferred: reachable from anywhere)
// or this node's host, rather than degrading to just the pubkey.
const pubkey = (g && g.identity_pubkey) || '';
setText('nodePubkey', pubkey || '—');
const uris = (g && g.uris) || [];
const uriWrap = document.getElementById('nodeUriWrap');
let uri = '';
let hint = '';
if (uris.length) {
uri = uris[0];
hint = uris.length > 1 ? uris.length + ' advertised addresses' : 'Advertised by your node';
} else if (pubkey) {
const onion = lndConnInfo && lndConnInfo.tor_onion;
uri = pubkey + '@' + (onion || host) + ':' + P2P_PORT;
hint = onion
? 'Built from your Tor address — your node is not advertising one yet.'
: 'Built from this nodes local address. It is only reachable from your network until Tor or a public address is configured.';
}
if (uri) {
uriWrap.style.display = '';
setText('nodeUri', uris[0]);
setText('nodeUriHint', uris.length > 1 ? uris.length + ' advertised addresses' : '');
renderQR('nodeIdQrBox', uris[0]);
} else if (g && g.identity_pubkey) {
uriWrap.style.display = 'none';
setText('nodeUriHint', '');
renderQR('nodeIdQrBox', g.identity_pubkey);
setText('nodeUri', uri);
setText('nodeUriHint', hint);
renderQR('nodeIdQrBox', uri);
}
}
@@ -1502,7 +1553,7 @@
'P2P: ' + host + ':' + P2P_PORT,
];
if (lndConnInfo && lndConnInfo.tor_onion) lines.push('Tor: ' + lndConnInfo.tor_onion);
navigator.clipboard.writeText(lines.join('\n')).then(() => flash(btn, 'Copied!'));
copyText(lines.join('\n')).then(() => flash(btn, 'Copied!'));
}
function flash(btn, msg) {
@@ -1584,7 +1635,7 @@
function copyEl(id, btn) {
const text = document.getElementById(id).textContent.trim();
if (!text || text === '—') return;
navigator.clipboard.writeText(text).then(() => {
copyText(text).then(() => {
const orig = btn.innerHTML;
btn.innerHTML = '<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>';
btn.style.color = '#4ade80';
@@ -1597,7 +1648,7 @@
const { isTor, port, connHost } = connSelection();
const uri = buildLndconnectUri(connHost, port, lndConnInfo.cert_base64url, lndConnInfo.macaroon_base64url, isTor);
const btn = document.getElementById('copyUriBtn');
navigator.clipboard.writeText(uri).then(() => flash(btn, 'Copied!'));
copyText(uri).then(() => flash(btn, 'Copied!'));
}
async function fetchConnectInfo() {
+12 -2
View File
@@ -578,11 +578,21 @@ load_spec_archy-lnd-ui() {
reset_spec
SPEC_NAME="archy-lnd-ui"
SPEC_IMAGE="localhost/lnd-ui:local"
SPEC_PORTS="18083:80"
# Host-networked, NOT bridge with 18083:80. docker/lnd-ui/nginx.conf listens
# on 18083 directly (it must, so it can proxy to the backend on
# 127.0.0.1:5678 without a cross-origin hop). This spec used to say
# SPEC_PORTS="18083:80", which published host 18083 to container port 80 —
# where nothing listens. Nobody noticed because the running containers were
# created by first-boot-containers.sh, which is host-networked and never
# consults this file; the spec is only read when self-update.sh rebuilds a
# UI image, and that only fires when a file under docker/lnd-ui/ changes.
# Verified on archi-dev-box: recreating from the old spec left :18083
# refusing connections.
SPEC_NETWORK="host"
SPEC_MEMORY="$(mem_limit archy-lnd-ui)"
SPEC_TIER="4"
SPEC_LOCAL_IMAGE="true"
SPEC_CAPS="CHOWN SETUID SETGID NET_BIND_SERVICE"
SPEC_CAPS="CHOWN SETUID SETGID"
SPEC_SECURITY="no-new-privileges:true"
}