diff --git a/docker/bitcoin-ui/index.html b/docker/bitcoin-ui/index.html
index 39ea52bf..ce7e2809 100644
--- a/docker/bitcoin-ui/index.html
+++ b/docker/bitcoin-ui/index.html
@@ -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 = '';
btn.style.color = '#4ade80';
diff --git a/docker/lnd-ui/index.html b/docker/lnd-ui/index.html
index 3c73411c..dc2f84cf 100644
--- a/docker/lnd-ui/index.html
+++ b/docker/lnd-ui/index.html
@@ -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 = '';
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() {
diff --git a/scripts/container-specs.sh b/scripts/container-specs.sh
index 43954c4a..c9c26e2e 100755
--- a/scripts/container-specs.sh
+++ b/scripts/container-specs.sh
@@ -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"
}