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:
co-authored by
Claude Opus 5
parent
5c9d5dc424
commit
aaa89789d2
@@ -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 => ({
|
||||
'&': '&',
|
||||
@@ -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
@@ -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 =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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 node’s 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() {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user