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