From f394c020558b802053a50ff27fa17c8f174f910a Mon Sep 17 00:00:00 2001 From: archipelago Date: Thu, 6 Aug 2026 09:55:52 -0400 Subject: [PATCH 1/9] =?UTF-8?q?fix(mesh):=20ride=20out=20the=20radio=20res?= =?UTF-8?q?tart=20during=20apply=20=E2=80=94=20no=20false=20errors,=20no?= =?UTF-8?q?=20setup=20modal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applying RF settings deliberately restarts the radio daemon (~15-20s). Two things treated that healthy, expected gap as a fault (operator, 2026-08-06): - radio_state was single-shot: a query landing inside the restart window reported "The radio daemon did not answer the state query" for a restart that was working correctly. It now retries for ~30s and says the radio is restarting while it waits. A real device-level refusal (not an RNode) still returns immediately. - The device-setup modal auto-opens for any detected-but-unconnected port, so the restart looked like a newly plugged stick and interrupted the apply. Apply and Reboot now suppress auto-detect for 90s via mesh.suppressDeviceDetect(). Co-Authored-By: Claude Fable 5 --- core/archipelago/src/mesh/mod.rs | 59 +++++++++++++++------ neode-ui/src/stores/mesh.ts | 11 ++++ neode-ui/src/views/mesh/MeshDevicePanel.vue | 5 ++ 3 files changed, 60 insertions(+), 15 deletions(-) diff --git a/core/archipelago/src/mesh/mod.rs b/core/archipelago/src/mesh/mod.rs index 629bae36..db40868f 100644 --- a/core/archipelago/src/mesh/mod.rs +++ b/core/archipelago/src/mesh/mod.rs @@ -2155,22 +2155,51 @@ impl MeshService { /// interface including the radio-confirmed r_* parameters. The LoRa /// settings panel's source for "what is the device actually running". pub async fn radio_state(&self) -> Result { - let status = self.state.status.read().await; - if !status.device_connected { - anyhow::bail!("No mesh device connected. Check USB connection."); + // Retry across a reconnect window. Applying settings deliberately + // restarts the radio daemon (~15s), and the session is legitimately + // absent while it comes back — a single-shot query inside that window + // reported "the daemon did not answer" for what is a healthy, + // in-progress restart (operator, 2026-08-06). + const ATTEMPTS: u32 = 6; + let mut last_err = anyhow::anyhow!("No mesh device connected. Check USB connection."); + for attempt in 0..ATTEMPTS { + if attempt > 0 { + tokio::time::sleep(std::time::Duration::from_secs(4)).await; + } + if !self.state.status.read().await.device_connected { + last_err = anyhow::anyhow!( + "The radio is not connected right now — if settings were just applied it \ + is restarting and comes back within about 20 seconds." + ); + continue; + } + let (tx, rx) = tokio::sync::oneshot::channel(); + if self + .state + .send_cmd(listener::MeshCommand::QueryRadioState { reply: tx }) + .await + .is_err() + { + last_err = anyhow::anyhow!("Mesh listener not running"); + continue; + } + match tokio::time::timeout(std::time::Duration::from_secs(10), rx).await { + Ok(Ok(Ok(state))) => return Ok(state), + Ok(Ok(Err(e))) => { + // A real device-level refusal (e.g. not an RNode radio) — + // retrying cannot change it. + return Err(anyhow::anyhow!(e)); + } + Ok(Err(_)) => { + last_err = + anyhow::anyhow!("Mesh session ended before the state query completed") + } + Err(_) => { + last_err = anyhow::anyhow!("The radio daemon did not answer the state query") + } + } } - drop(status); - - let (tx, rx) = tokio::sync::oneshot::channel(); - self.state - .send_cmd(listener::MeshCommand::QueryRadioState { reply: tx }) - .await - .map_err(|_| anyhow::anyhow!("Mesh listener not running"))?; - let state = tokio::time::timeout(std::time::Duration::from_secs(10), rx) - .await - .map_err(|_| anyhow::anyhow!("The radio daemon did not answer the state query"))? - .map_err(|_| anyhow::anyhow!("Mesh session ended before the state query completed"))?; - state.map_err(|e| anyhow::anyhow!(e)) + Err(last_err) } /// Current mesh-AI assistant settings (issue #50). diff --git a/neode-ui/src/stores/mesh.ts b/neode-ui/src/stores/mesh.ts index b8a80a56..db7379e6 100644 --- a/neode-ui/src/stores/mesh.ts +++ b/neode-ui/src/stores/mesh.ts @@ -356,9 +356,19 @@ export const useMeshStore = defineStore('mesh', () => { // The modal waits for 2 sightings so it doesn't flash during the couple of // seconds an ordinary reconnect (same radio, transient blip) needs. const detectSightings = ref>({}) + /** Epoch-ms until which the device-setup modal must NOT auto-open: an + * operator-initiated radio restart (settings apply, Reboot Radio) takes + * the radio down for ~15-20s, and the modal treated that healthy, + * expected gap as "a new stick was plugged in" and interrupted the flow + * (operator, 2026-08-06). */ + const suppressDetectUntil = ref(0) + function suppressDeviceDetect(ms = 90_000) { + suppressDetectUntil.value = Date.now() + ms + } const undismissedDetectedDevices = computed(() => { const s = status.value if (!s) return [] + if (Date.now() < suppressDetectUntil.value) return [] return (s.detected_devices || []).filter(p => dismissedDetected.value[p] !== pluggedAt(s, p) && // The port the live session occupies is not a candidate… @@ -1148,6 +1158,7 @@ export const useMeshStore = defineStore('mesh', () => { latestBlockHeight, fetchStatus, undismissedDetectedDevices, + suppressDeviceDetect, dismissDetectedDevice, flashFlowPath, openFlashFlow, diff --git a/neode-ui/src/views/mesh/MeshDevicePanel.vue b/neode-ui/src/views/mesh/MeshDevicePanel.vue index 1345b321..6a01b979 100644 --- a/neode-ui/src/views/mesh/MeshDevicePanel.vue +++ b/neode-ui/src/views/mesh/MeshDevicePanel.vue @@ -13,6 +13,8 @@ async function handleReboot() { rebooting.value = true rebootError.value = null rebootMessage.value = null + // Same as apply: the radio goes away on purpose for ~15-20s. + mesh.suppressDeviceDetect() try { const res = await mesh.rebootRadio() // The backend now waits for the device's acknowledgement and says what @@ -101,6 +103,9 @@ async function loadRnodeConfig() { async function applyRnodeSettings() { rnodeApplying.value = true rnodeResult.value = null + // Applying deliberately restarts the radio daemon; without this the + // "new device detected" modal interrupts the flow mid-apply. + mesh.suppressDeviceDetect() try { const res = await mesh.applyRnodeConfig({ enabled: rnodeForm.value.enabled, From 4773b32a7602ad073c293dffd3bd39c7f4ff3a1c Mon Sep 17 00:00:00 2001 From: archipelago Date: Thu, 6 Aug 2026 10:31:01 -0400 Subject: [PATCH 2/9] feat(mesh-ui): region-recommended RNode plan applies from the setup modal too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The device-detected modal's region selector now drives real RNode settings instead of a "managed by the daemon config" shrug: choosing a region shows its concrete plan (frequency/bw/SF/CR/power) and Apply & Connect writes it through mesh.rnode-config-apply — the same radio-confirmed round-trip as the Device panel, best-effort so a plan failure never aborts the connect. RNODE_REGION_PLANS moves to utils/loraRegions (single source shared by panel + modal). Co-Authored-By: Claude Fable 5 --- .../components/mesh/MeshDeviceSetupModal.vue | 21 +++++++++++++--- neode-ui/src/utils/loraRegions.ts | 24 +++++++++++++++++++ neode-ui/src/views/mesh/MeshDevicePanel.vue | 15 +----------- 3 files changed, 43 insertions(+), 17 deletions(-) diff --git a/neode-ui/src/components/mesh/MeshDeviceSetupModal.vue b/neode-ui/src/components/mesh/MeshDeviceSetupModal.vue index 895e910a..29ad023c 100644 --- a/neode-ui/src/components/mesh/MeshDeviceSetupModal.vue +++ b/neode-ui/src/components/mesh/MeshDeviceSetupModal.vue @@ -256,8 +256,11 @@

MeshCore RF params for {{ selectedRegion?.code }} are applied to the radio automatically on connect.

-

- RNode radio parameters (frequency / bandwidth / SF / CR) are managed by the Reticulum daemon's interface config on this node. +

+ RNode plan for {{ form.region }}: {{ (RNODE_REGION_PLANS[form.region].frequency / 1e6).toFixed(4) }} MHz, {{ RNODE_REGION_PLANS[form.region].bandwidth / 1000 }} kHz, SF{{ RNODE_REGION_PLANS[form.region].spreading_factor }}, CR4/{{ RNODE_REGION_PLANS[form.region].coding_rate }}, {{ RNODE_REGION_PLANS[form.region].txpower }} dBm — applied on connect, and the radio confirms it. +

+

+ Pick a region to apply its recommended RNode RF plan on connect — editable any time in Mesh → Device settings.

@@ -302,7 +305,7 @@ import { useRouter } from 'vue-router' import BaseModal from '@/components/BaseModal.vue' import { useMeshStore, type MeshDeviceProbe, type MeshConfigureParams, type FlashFirmwareFamily, type FlashBoard, type FlashJobStatus } from '@/stores/mesh' import { useAppStore } from '@/stores/app' -import { LORA_REGIONS, regionByCode, suggestRegionFromLatLon, meshcorePlanFor } from '@/utils/loraRegions' +import { LORA_REGIONS, regionByCode, suggestRegionFromLatLon, meshcorePlanFor, RNODE_REGION_PLANS } from '@/utils/loraRegions' import { resolveMeshDeviceImage } from '@/utils/meshDeviceImages' const mesh = useMeshStore() @@ -501,6 +504,18 @@ async function applySetup() { } } await mesh.configure(params) + // RNode radios: the region's recommended RF plan is applied through the + // Reticulum daemon's persisted settings (mesh.rnode-config-apply), the + // same round-trip the Device panel uses — the radio confirms the values + // itself after the daemon restarts with them. Best-effort here: a + // failure must not abort the connect the user just asked for. + if (effectiveKind.value === 'reticulum' && form.value.region) { + const plan = RNODE_REGION_PLANS[form.value.region] + if (plan) { + mesh.suppressDeviceDetect() + void mesh.applyRnodeConfig({ enabled: true, port: null, ...plan }).catch(() => {}) + } + } mesh.dismissDetectedDevice(path) void router.push('/dashboard/mesh') } catch (e) { diff --git a/neode-ui/src/utils/loraRegions.ts b/neode-ui/src/utils/loraRegions.ts index f9415388..1ad20d7e 100644 --- a/neode-ui/src/utils/loraRegions.ts +++ b/neode-ui/src/utils/loraRegions.ts @@ -126,3 +126,27 @@ export const MESHCORE_RF_PRESETS: MeshcoreRfPreset[] = [ { id: 'us_anz_915', label: 'US / Canada / ANZ — 915.0 MHz, 250 kHz, SF10, CR4/5', freqMhz: 915.0, bwKhz: 250, sf: 10, cr: 5 }, { id: 'eu_433', label: 'Europe (433 MHz) — 433.65 MHz, 250 kHz, SF11, CR4/5', freqMhz: 433.65, bwKhz: 250, sf: 11, cr: 5 }, ] + +/** Recommended Reticulum RNode RF plan per region. Applied via + * mesh.rnode-config-apply (the daemon restarts the radio with these and the + * radio confirms them back). EU868 is the operator-validated Portugal plan: + * 869.4625 MHz sits in the 10%-duty 869.4–869.65 sub-band clear of the + * default community channel, with the EU airtime locks written in. Others + * follow RNS community conventions with the region's legal power cap. */ +export interface RnodeRegionPlan { + frequency: number + bandwidth: number + spreading_factor: number + coding_rate: number + txpower: number + airtime_limit_short: number | null + airtime_limit_long: number | null +} +export const RNODE_REGION_PLANS: Record = { + EU868: { frequency: 869462500, bandwidth: 125000, spreading_factor: 8, coding_rate: 5, txpower: 14, airtime_limit_short: 25, airtime_limit_long: 10 }, + US915: { frequency: 914875000, bandwidth: 125000, spreading_factor: 8, coding_rate: 5, txpower: 17, airtime_limit_short: null, airtime_limit_long: null }, + AU915: { frequency: 916800000, bandwidth: 125000, spreading_factor: 8, coding_rate: 5, txpower: 17, airtime_limit_short: null, airtime_limit_long: null }, + ANZ: { frequency: 916800000, bandwidth: 125000, spreading_factor: 8, coding_rate: 5, txpower: 17, airtime_limit_short: null, airtime_limit_long: null }, + AS923: { frequency: 923200000, bandwidth: 125000, spreading_factor: 8, coding_rate: 5, txpower: 13, airtime_limit_short: null, airtime_limit_long: null }, + IN865: { frequency: 866000000, bandwidth: 125000, spreading_factor: 8, coding_rate: 5, txpower: 17, airtime_limit_short: null, airtime_limit_long: null }, +} diff --git a/neode-ui/src/views/mesh/MeshDevicePanel.vue b/neode-ui/src/views/mesh/MeshDevicePanel.vue index 6a01b979..78788e82 100644 --- a/neode-ui/src/views/mesh/MeshDevicePanel.vue +++ b/neode-ui/src/views/mesh/MeshDevicePanel.vue @@ -1,7 +1,7 @@ + + diff --git a/neode-ui/src/views/settings/SystemSection.vue b/neode-ui/src/views/settings/SystemSection.vue index abc794ae..a4b8ab3f 100644 --- a/neode-ui/src/views/settings/SystemSection.vue +++ b/neode-ui/src/views/settings/SystemSection.vue @@ -5,6 +5,7 @@ import ClaudeAuthSection from '@/views/settings/ClaudeAuthSection.vue' import AIDataAccessSection from '@/views/settings/AIDataAccessSection.vue' import WebhookSection from '@/views/settings/WebhookSection.vue' import TelemetrySection from '@/views/settings/TelemetrySection.vue' +import NodeCertificateSection from '@/views/settings/NodeCertificateSection.vue' import BackupSection from '@/views/settings/BackupSection.vue' import SystemDangerZone from '@/views/settings/SystemDangerZone.vue' @@ -16,6 +17,7 @@ import SystemDangerZone from '@/views/settings/SystemDangerZone.vue' + diff --git a/scripts/setup-node-ca.sh b/scripts/setup-node-ca.sh new file mode 100755 index 00000000..727d4f19 --- /dev/null +++ b/scripts/setup-node-ca.sh @@ -0,0 +1,149 @@ +#!/usr/bin/env bash +# Per-node certificate authority. +# +# WHY THIS EXISTS +# +# The node used to serve a bare self-signed leaf (setup-https-dev.sh). A browser +# can be told to trust that, but the exception is granted per ORIGIN — scheme + +# host + PORT. The dashboard on :443 and an app on :8334 are different origins, +# so each app port needed its own click-through, and a cert interstitial CANNOT +# be accepted inside an iframe: the embedded app just fails. +# +# A CA fixes that structurally. The user installs ONE certificate; every leaf it +# signs is then trusted, on every port, with no further prompts. Ports are not +# part of a certificate's identity — one leaf with the right SANs covers every +# port on the host — so this is what makes gated apps embeddable over HTTPS. +# +# The CA private key never leaves the node and signs nothing but this node's own +# leaf. Installing it means trusting THIS node, not a third party. +# +# Idempotent: re-running reuses an existing CA and only reissues the leaf (which +# is what you want when the node gains an address). Pass --force-ca to start over +# — that invalidates every copy users have already installed. + +set -euo pipefail + +SSL_DIR="${ARCHY_SSL_DIR:-/etc/archipelago/ssl}" +CA_CRT="$SSL_DIR/ca.crt" +CA_KEY="$SSL_DIR/ca.key" +CA_SRL="$SSL_DIR/ca.srl" +LEAF_CRT="$SSL_DIR/archipelago.crt" +LEAF_KEY="$SSL_DIR/archipelago.key" + +CA_DAYS="${ARCHY_CA_DAYS:-3650}" +# Public CAs cap leaves at 398 days and browsers enforce it. That limit applies +# to publicly-trusted roots, not a privately-installed one, but a shorter leaf +# still bounds the damage from a key leak — and reissuing costs nothing here +# because this script is re-run on address changes anyway. +LEAF_DAYS="${ARCHY_LEAF_DAYS:-397}" + +FORCE_CA=false +[ "${1:-}" = "--force-ca" ] && FORCE_CA=true + +NODE_NAME="$(hostname -s 2>/dev/null || echo archipelago)" + +log() { echo " $*"; } + +mkdir -p "$SSL_DIR" +chmod 755 "$SSL_DIR" + +# --- Subject alternative names ----------------------------------------------- +# Every name/address the node can be reached by must be in the leaf, because a +# certificate is scoped to names, not ports. Missing one here means that access +# path still throws a warning even after the CA is installed. +collect_sans() { + local -a dns=() ips=() + + dns+=("archipelago.local" "$NODE_NAME" "$NODE_NAME.local" "localhost") + + # Tailscale gives a stable MagicDNS name; include it so tailnet access is clean. + if command -v tailscale >/dev/null 2>&1; then + local ts_name + ts_name="$(tailscale status --json 2>/dev/null \ + | python3 -c 'import json,sys; d=json.load(sys.stdin); print((d.get("Self") or {}).get("DNSName","").rstrip("."))' 2>/dev/null || true)" + [ -n "$ts_name" ] && dns+=("$ts_name") + fi + + # Every non-loopback address the host currently holds, plus loopback itself. + ips+=("127.0.0.1" "::1") + while read -r addr; do + [ -n "$addr" ] && ips+=("$addr") + done < <(ip -o addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | sort -u) + + local out="" i=1 j=1 + for d in $(printf '%s\n' "${dns[@]}" | awk 'NF' | sort -u); do + out="${out}DNS.$i:$d,"; i=$((i+1)) + done + for a in $(printf '%s\n' "${ips[@]}" | awk 'NF' | sort -u); do + out="${out}IP.$j:$a,"; j=$((j+1)) + done + echo "${out%,}" +} + +SAN="$(collect_sans)" +[ -z "$SAN" ] && { echo "ERROR: no SANs resolved — refusing to issue a useless cert" >&2; exit 1; } + +# --- CA ---------------------------------------------------------------------- +if [ "$FORCE_CA" = true ] && [ -f "$CA_CRT" ]; then + log "--force-ca: replacing the existing CA (previously installed copies stop working)" + rm -f "$CA_CRT" "$CA_KEY" "$CA_SRL" +fi + +if [ -f "$CA_CRT" ] && [ -f "$CA_KEY" ]; then + log "Reusing the existing node CA (installed copies keep working)" +else + log "Creating this node's certificate authority…" + openssl req -x509 -nodes -newkey rsa:4096 -sha256 -days "$CA_DAYS" \ + -keyout "$CA_KEY" -out "$CA_CRT" \ + -subj "/CN=Archipelago Node CA ($NODE_NAME)/O=Archipelago/OU=Node CA" \ + -addext "basicConstraints=critical,CA:TRUE,pathlen:0" \ + -addext "keyUsage=critical,keyCertSign,cRLSign" 2>/dev/null + chmod 600 "$CA_KEY" + chmod 644 "$CA_CRT" +fi + +# --- Leaf -------------------------------------------------------------------- +log "Issuing the server certificate for: $SAN" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +openssl req -nodes -newkey rsa:2048 -sha256 \ + -keyout "$TMP/leaf.key" -out "$TMP/leaf.csr" \ + -subj "/CN=$NODE_NAME/O=Archipelago" 2>/dev/null + +cat >"$TMP/leaf.ext" </dev/null + +# Swap in place only once both halves exist, so a failure mid-run cannot leave +# nginx pointing at a cert whose key is gone. +install -m 644 "$TMP/leaf.crt" "$LEAF_CRT" +install -m 600 "$TMP/leaf.key" "$LEAF_KEY" + +# The dashboard serves this for download; it is a public certificate, never the key. +install -m 644 "$CA_CRT" "$SSL_DIR/ca-download.crt" + +FP="$(openssl x509 -in "$CA_CRT" -noout -fingerprint -sha256 | cut -d= -f2)" +log "CA fingerprint (SHA-256): $FP" + +if command -v systemctl >/dev/null 2>&1 && systemctl is-active --quiet nginx; then + if nginx -t >/dev/null 2>&1; then + systemctl reload nginx && log "nginx reloaded" + else + echo "WARNING: nginx config test failed — NOT reloading. Certs are in place; fix nginx and reload." >&2 + fi +fi + +cat < Date: Thu, 6 Aug 2026 14:43:08 -0400 Subject: [PATCH 8/9] fix(ui): app frames follow the dashboard's scheme instead of forcing http MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both schemes now work, and each one works properly: - HTTP dashboard -> http app origin (unchanged; no certificate needed) - HTTPS dashboard -> https app origin (needs the node CA + TLS on the port) The app URL was hardcoded to http://, which on an HTTPS dashboard is mixed content — blocked outright, before the SameSite cookie question the symptom was filed under. It is also what made the two origins schemefully cross-site, so following the page's scheme fixes both causes at once. Backend-reported runtime URLs get the same treatment: the daemon reports http:// because that is how the app binds locally, which is right for the node and wrong for a browser on an HTTPS page. pageScheme() defaults to http when location.protocol is absent (non-browser contexts) — the safe direction, since inventing an https URL for a port that serves no TLS would break a working setup. That default is also why the three existing resolveAppUrl tests, whose fixture stubs location without a protocol, keep passing unmodified rather than being edited to fit. Co-Authored-By: Claude Opus 5 (1M context) --- .../appSession/__tests__/appOrigin.test.ts | 55 +++++++++++++++++++ .../src/views/appSession/appSessionConfig.ts | 45 ++++++++++++++- 2 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 neode-ui/src/views/appSession/__tests__/appOrigin.test.ts diff --git a/neode-ui/src/views/appSession/__tests__/appOrigin.test.ts b/neode-ui/src/views/appSession/__tests__/appOrigin.test.ts new file mode 100644 index 00000000..79e7661f --- /dev/null +++ b/neode-ui/src/views/appSession/__tests__/appOrigin.test.ts @@ -0,0 +1,55 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { appOrigin, matchPageScheme } from '../appSessionConfig' + +// An HTTPS dashboard cannot embed an HTTP app frame — browsers block it as +// mixed content — so the app origin has to follow the page's scheme. Plain-HTTP +// nodes must be completely unaffected, which is what most of these pin. + +function setLocation(protocol: string, hostname: string) { + Object.defineProperty(window, 'location', { + value: { protocol, hostname }, + writable: true, + configurable: true, + }) +} + +afterEach(() => vi.unstubAllGlobals()) + +describe('appOrigin', () => { + it('stays on http for an http dashboard', () => { + setLocation('http:', 'archi-dev-box') + expect(appOrigin(8334)).toBe('http://archi-dev-box:8334') + }) + + it('follows an https dashboard onto the app port', () => { + setLocation('https:', 'archi-dev-box') + expect(appOrigin(8334)).toBe('https://archi-dev-box:8334') + }) + + it('keeps the hostname the user actually typed, not a fixed name', () => { + setLocation('https:', '100.69.68.39') + expect(appOrigin(3000)).toBe('https://100.69.68.39:3000') + }) +}) + +describe('matchPageScheme', () => { + it('leaves backend-reported http URLs alone on an http page', () => { + setLocation('http:', 'node') + expect(matchPageScheme('http://node:8080/app')).toBe('http://node:8080/app') + }) + + it('upgrades a backend-reported http URL on an https page', () => { + setLocation('https:', 'node') + expect(matchPageScheme('http://node:8080/app')).toBe('https://node:8080/app') + }) + + it('does not touch anything but the scheme', () => { + setLocation('https:', 'node') + expect(matchPageScheme('http://node:8080/a/b?c=1#d')).toBe('https://node:8080/a/b?c=1#d') + }) + + it('leaves an already-https URL untouched', () => { + setLocation('https:', 'node') + expect(matchPageScheme('https://node:8080/app')).toBe('https://node:8080/app') + }) +}) diff --git a/neode-ui/src/views/appSession/appSessionConfig.ts b/neode-ui/src/views/appSession/appSessionConfig.ts index 8f614f49..422e55ee 100644 --- a/neode-ui/src/views/appSession/appSessionConfig.ts +++ b/neode-ui/src/views/appSession/appSessionConfig.ts @@ -107,11 +107,15 @@ export function resolveAppUrl(id: string, routeQueryPath?: string, runtimeUrl?: // shell when proxied under a path prefix on some nodes. if (id === 'bitcoin-knots' || id === 'bitcoin-core' || id === 'bitcoin-ui') { if (import.meta.env.DEV) return '/app/bitcoin-ui/' - return 'http://' + window.location.hostname + ':8334' + return appOrigin(8334) } if (runtimeUrl && id !== 'netbird') { let base = runtimeUrl.replace(/localhost/i, window.location.hostname) + // The backend reports runtime URLs as http:// because that is how the app + // binds locally. Sent to a browser on an HTTPS dashboard that is mixed + // content and the frame is blocked outright, so follow the page instead. + base = matchPageScheme(base) if (routeQueryPath) base += routeQueryPath return base } @@ -120,11 +124,48 @@ export function resolveAppUrl(id: string, routeQueryPath?: string, runtimeUrl?: const port = APP_PORTS[id] if (!port) return '' - let base = 'http://' + window.location.hostname + ':' + String(port) + let base = appOrigin(port) if (routeQueryPath) base += routeQueryPath return base } +/** + * An app's origin on this host, on the SAME scheme as the page. + * + * An HTTPS dashboard cannot embed an HTTP frame at all — browsers block it as + * mixed content before any cookie question arises — and it is also what makes + * the two origins schemefully cross-site, so the session cookie is withheld. + * Following the page's scheme fixes both at once and keeps plain HTTP working + * exactly as before on nodes that serve the dashboard over HTTP. + * + * On HTTPS this requires the app port to actually serve TLS with a certificate + * the browser trusts — see scripts/setup-node-ca.sh and Settings → System → + * Node certificate. A certificate warning cannot be accepted inside an iframe, + * so an untrusted app port renders nothing rather than prompting. + */ +export function appOrigin(port: number): string { + return `${pageScheme()}//${window.location.hostname}:${port}` +} + +/** Rewrite a URL's scheme to the page's, leaving everything else alone. */ +export function matchPageScheme(url: string): string { + if (pageScheme() !== 'https:') return url + return url.replace(/^http:\/\//i, 'https://') +} + +/** + * The page's scheme, defaulting to http. + * + * A real browser always has location.protocol; this defends the non-browser + * cases (tests, SSR-ish contexts) where it can be absent. Defaulting to http + * is the safe direction — it preserves today's behaviour rather than inventing + * an https URL for a port that may not serve TLS. + */ +function pageScheme(): string { + const p = window.location?.protocol + return p === 'https:' || p === 'http:' ? p : 'http:' +} + /** Resolve a human-readable title for an app */ export function resolveAppTitle(id: string): string { return APP_TITLES[id] || id.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) From 7515166a073a66550d10be837ed31f7928937ff8 Mon Sep 17 00:00:00 2001 From: archipelago Date: Thu, 6 Aug 2026 15:23:47 -0400 Subject: [PATCH 9/9] feat(appgate): serve HTTPS and HTTP on the same app port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An app port must answer whatever the browser asks for: an HTTP dashboard embeds http://host:PORT, an HTTPS one embeds https://host:PORT, and an HTTPS page cannot embed an HTTP frame at all. So the choice is per-node, not per-fleet, and a second port number would mean every manifest changes and torrc doubles. Instead the gate peeks the first byte. A TLS ClientHello is 0x16; no HTTP method starts with it. peek() leaves the bytes in the socket buffer, so the acceptor still sees a complete, untouched ClientHello. TLS and plain share one generic serve_http(), so authentication, proxying and upgrade handling cannot drift apart by scheme. EXISTING NODES ARE UNAFFECTED BY CONSTRUCTION. Anything that is not a TLS handshake takes the identical path as before, and a node with no certificate serves plain HTTP exactly as today — TLS is strictly additive. rustls does NOT verify that a private key matches its certificate. Established by test, not assumed: with_single_cert accepted a pair from two different keys and would only have failed mid-handshake in a user's browser — a security control that reports success and does nothing, the exact shape this module's own docs warn about. So the pairing is now proven explicitly (sign a fixed message with the key, verify against the certificate's public key) and a mismatch refuses to serve. Also: cert and key mtimes are stamped as a PAIR, because reissuing writes them separately and keying on one would serve a certificate that no longer matches its key; a 15s first-byte timeout closes the slowloris window one step earlier than the existing header-read timeout; PKCS#8 and PKCS#1 keys are both accepted so a hand-made key does not silently downgrade a working node. Deps pinned to the rustls 0.21 line reqwest already resolves — no new vendor, no second rustls major. Test fixtures are throwaway (localhost SANs only), not any node's identity. 38/38 appgate tests pass. Co-Authored-By: Claude Opus 5 (1M context) --- core/Cargo.lock | 3 + core/archipelago/Cargo.toml | 7 + core/archipelago/src/appgate/listener.rs | 98 ++++- core/archipelago/src/appgate/mod.rs | 6 + .../src/appgate/testdata/README.md | 10 + .../archipelago/src/appgate/testdata/leaf.crt | 21 + .../archipelago/src/appgate/testdata/leaf.key | 28 ++ .../src/appgate/testdata/other.key | 28 ++ core/archipelago/src/appgate/tls.rs | 393 ++++++++++++++++++ 9 files changed, 577 insertions(+), 17 deletions(-) create mode 100644 core/archipelago/src/appgate/testdata/README.md create mode 100644 core/archipelago/src/appgate/testdata/leaf.crt create mode 100644 core/archipelago/src/appgate/testdata/leaf.key create mode 100644 core/archipelago/src/appgate/testdata/other.key create mode 100644 core/archipelago/src/appgate/tls.rs diff --git a/core/Cargo.lock b/core/Cargo.lock index 90a90887..4b2f02eb 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -147,6 +147,8 @@ dependencies = [ "reed-solomon-erasure", "regex", "reqwest 0.11.27", + "rustls-pemfile", + "rustls-webpki 0.101.7", "sd-notify", "serde", "serde_bytes", @@ -159,6 +161,7 @@ dependencies = [ "tempfile", "thiserror 1.0.69", "tokio", + "tokio-rustls 0.24.1", "tokio-test", "tokio-tungstenite 0.20.1", "toml", diff --git a/core/archipelago/Cargo.toml b/core/archipelago/Cargo.toml index dfffcf95..4760f754 100644 --- a/core/archipelago/Cargo.toml +++ b/core/archipelago/Cargo.toml @@ -80,6 +80,13 @@ serde_yaml = "0.9" # HTTP client (for LND REST proxy, Tor SOCKS for peer messaging) # Uses rustls-tls for cross-compilation (no OpenSSL dependency) +# App-gate TLS. Pinned to the rustls 0.21 line that reqwest already resolves, +# so this adds no new vendor and no second rustls major to the tree. +tokio-rustls = "0.24" +rustls-pemfile = "1.0" +# Verifying that the gate's key actually pairs with its certificate; rustls +# does not check this itself. Same version rustls 0.21 already resolves. +webpki = { package = "rustls-webpki", version = "0.101" } reqwest = { version = "0.11", default-features = false, features = ["json", "socks", "rustls-tls", "stream"] } # Nostr (node discovery + NIP-44 encrypted peer handshake) diff --git a/core/archipelago/src/appgate/listener.rs b/core/archipelago/src/appgate/listener.rs index 0a7844da..3699dac2 100644 --- a/core/archipelago/src/appgate/listener.rs +++ b/core/archipelago/src/appgate/listener.rs @@ -331,23 +331,7 @@ fn spawn_accept_loop( let gate = gate.clone(); let app = app.clone(); tokio::spawn(async move { - let service = hyper::service::service_fn(move |req| { - let gate = gate.clone(); - let app = app.clone(); - async move { - Ok::<_, std::convert::Infallible>( - gate.handle(req, &app, peer.ip()).await, - ) - } - }); - let _ = hyper::server::conn::Http::new() - // Same slowloris guard as the main listener: an - // unauthenticated caller must not be able to hold - // a connection open by never sending headers. - .http1_header_read_timeout(std::time::Duration::from_secs(30)) - .serve_connection(stream, service) - .with_upgrades() - .await; + serve_connection(stream, peer, gate, app).await; }); } _ = shutdown_rx.changed() => break, @@ -356,6 +340,86 @@ fn spawn_accept_loop( }) } +/// How long a freshly-accepted connection has to send its first byte. +/// +/// The peek below blocks until *something* arrives, so without this an +/// unauthenticated caller could hold a task open indefinitely by connecting and +/// saying nothing — the same slowloris shape the header-read timeout guards +/// against, one step earlier in the handshake. +const FIRST_BYTE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15); + +/// Serve one connection, as TLS or plain HTTP depending on what the client +/// actually sent. +/// +/// The first byte decides: `peek` inspects it *without consuming it*, so a TLS +/// client's ClientHello reaches the acceptor whole. This is what lets one port +/// serve an HTTP dashboard's frames and an HTTPS dashboard's frames on the same +/// node without a second port number or a per-node build. +async fn serve_connection( + stream: tokio::net::TcpStream, + peer: SocketAddr, + gate: Arc, + app: GatedPort, +) { + let mut first = [0u8; 1]; + let peeked = tokio::time::timeout(FIRST_BYTE_TIMEOUT, stream.peek(&mut first)).await; + + let is_tls = match peeked { + Ok(Ok(1)) => super::tls::looks_like_tls(first[0]), + // 0 bytes is a clean close before any request; anything else is a + // read error or the timeout. Nothing to serve either way. + _ => { + debug!(%peer, "app gate connection closed before sending anything"); + return; + } + }; + + if is_tls { + match gate.tls.acceptor().await { + Some(acceptor) => match acceptor.accept(stream).await { + Ok(tls_stream) => serve_http(tls_stream, peer, gate, app).await, + Err(e) => { + // Routine: a browser probing a cert it does not trust, or a + // scanner. Not operator-actionable, so debug. + debug!(%peer, error = %e, "app gate TLS handshake failed"); + } + }, + None => { + // The client speaks TLS and this node has no certificate. + // Replying in plain HTTP would be unreadable garbage to it, so + // close and let the browser report the connection failure. + debug!( + %peer, + "app gate got a TLS connection but has no certificate — closing" + ); + } + } + } else { + serve_http(stream, peer, gate, app).await; + } +} + +/// The HTTP half, generic over the transport so TLS and plain share one path — +/// the gate's authentication, proxying and upgrade handling must not differ by +/// scheme, and generics make that structural rather than a thing to remember. +async fn serve_http(stream: S, peer: SocketAddr, gate: Arc, app: GatedPort) +where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static, +{ + let service = hyper::service::service_fn(move |req| { + let gate = gate.clone(); + let app = app.clone(); + async move { Ok::<_, std::convert::Infallible>(gate.handle(req, &app, peer.ip()).await) } + }); + let _ = hyper::server::conn::Http::new() + // Same slowloris guard as the main listener: an unauthenticated caller + // must not be able to hold a connection open by never sending headers. + .http1_header_read_timeout(std::time::Duration::from_secs(30)) + .serve_connection(stream, service) + .with_upgrades() + .await; +} + #[cfg(test)] mod tests { use super::*; diff --git a/core/archipelago/src/appgate/mod.rs b/core/archipelago/src/appgate/mod.rs index a3ed00ef..0df769d0 100644 --- a/core/archipelago/src/appgate/mod.rs +++ b/core/archipelago/src/appgate/mod.rs @@ -35,6 +35,7 @@ pub mod identity; pub mod listener; +pub mod tls; use crate::auth::AuthManager; use crate::rate_limit::LoginRateLimiter; @@ -65,6 +66,10 @@ pub struct AppGate { limiter: LoginRateLimiter, data_dir: PathBuf, port_map: Arc>, + /// TLS for gated ports. Shared by every accept loop so one reissue is + /// picked up by all of them, and so the parse happens once rather than + /// per port. + pub(crate) tls: Arc, } impl AppGate { @@ -80,6 +85,7 @@ impl AppGate { limiter, data_dir, port_map: Arc::new(RwLock::new(identity::build_port_map())), + tls: Arc::new(tls::GateTls::new()), } } diff --git a/core/archipelago/src/appgate/testdata/README.md b/core/archipelago/src/appgate/testdata/README.md new file mode 100644 index 00000000..95ecd244 --- /dev/null +++ b/core/archipelago/src/appgate/testdata/README.md @@ -0,0 +1,10 @@ +Throwaway TLS fixtures for `appgate::tls` unit tests. + +Generated by `openssl req -x509 -nodes` with SANs `localhost`/`127.0.0.1` only. +They are **not** any node's identity: a real node's pair lives at +`/etc/archipelago/ssl/` and is created by `scripts/setup-node-ca.sh`. Nothing +here is trusted by anything, and `other.key` exists purely to prove a +mismatched cert/key pair is rejected rather than silently served. + +Regenerate with the command in this directory's git history if they ever +expire — `-days 36500` means that should not happen. diff --git a/core/archipelago/src/appgate/testdata/leaf.crt b/core/archipelago/src/appgate/testdata/leaf.crt new file mode 100644 index 00000000..526e7fa0 --- /dev/null +++ b/core/archipelago/src/appgate/testdata/leaf.crt @@ -0,0 +1,21 @@ +-----BEGIN CERTIFICATE----- +MIIDezCCAmOgAwIBAgIUT3u7aR6+q5j3ZITojvEaSt4mVWkwDQYJKoZIhvcNAQEL +BQAwPjEZMBcGA1UEAwwQYXJjaGlwZWxhZ28tdGVzdDEhMB8GA1UECgwYQXJjaGlw +ZWxhZ28gVGVzdCBGaXh0dXJlMCAXDTI2MDgwNjE4NDcyOFoYDzIxMjYwNzEzMTg0 +NzI4WjA+MRkwFwYDVQQDDBBhcmNoaXBlbGFnby10ZXN0MSEwHwYDVQQKDBhBcmNo +aXBlbGFnbyBUZXN0IEZpeHR1cmUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK +AoIBAQD6t1PeYAXxQVlLzfqn+6C1NFT609OiJmOx5d9uhKXIg7zu9KCqaRJWCDeJ +FBX/UEmWIJjJvB8GzLCzBNYLbcRDcFVGOPvo1SKaBDpFGACiAvkpez7TaRxhm6zK +qbUk2iuwm4BlGUGDCTtMxag6N94X/FPtQa2G8uD7D0MGi8lIYg4AGvPw8eKo2btl +wzOpUuxT5+SWWtX/wlDA+/YqSUvgbdh1gH/E013dqKPLgwdYuXnQdZ/wBkRLR60T +sjYXvCK/xfnZY0BSkMSAQEWkyesKr/nq2oJB8BYIns4npppmgmvaiTl0VMhHmrY5 +d1JYgHQ9Sgg41zLNtBR/RKU5L64/AgMBAAGjbzBtMB0GA1UdDgQWBBROknlP9RUU +DWQLCnXh1bXJtFSfPjAfBgNVHSMEGDAWgBROknlP9RUUDWQLCnXh1bXJtFSfPjAP +BgNVHRMBAf8EBTADAQH/MBoGA1UdEQQTMBGCCWxvY2FsaG9zdIcEfwAAATANBgkq +hkiG9w0BAQsFAAOCAQEAzncb5ju1O8Rls4vYspITYPJn5G8Vcc+N1uOnUwQF8ySC +MyaSd2TLYz+tyBCZ5JHuh9/gmhzReztarF/UDrDVQocqLn2G0xI7Q3ItYO7kqx0+ +qWXBa4Qd1ZIYL5Qi4kX8wJBWuym5Ib8XV9dvcFuwxOpXkFZfAH/hTFgs4csTs9Za +PulDhQPtUemtcerWoG65C9WplLw1DyitMeWpx/36iyVXBA5T2FIQnKsTtNt1Py1j +lsqrN5CTi1N9oZkTqkDjcbF9tqqx3NUCbFsBckMZ2lGizI12TlkGAeDqVPbZuyOj +psnc1Nu/EQEzcTYvPHJpMUwUOsJgDb2HWx5FAxy02Q== +-----END CERTIFICATE----- diff --git a/core/archipelago/src/appgate/testdata/leaf.key b/core/archipelago/src/appgate/testdata/leaf.key new file mode 100644 index 00000000..57cbcfc5 --- /dev/null +++ b/core/archipelago/src/appgate/testdata/leaf.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQD6t1PeYAXxQVlL +zfqn+6C1NFT609OiJmOx5d9uhKXIg7zu9KCqaRJWCDeJFBX/UEmWIJjJvB8GzLCz +BNYLbcRDcFVGOPvo1SKaBDpFGACiAvkpez7TaRxhm6zKqbUk2iuwm4BlGUGDCTtM +xag6N94X/FPtQa2G8uD7D0MGi8lIYg4AGvPw8eKo2btlwzOpUuxT5+SWWtX/wlDA ++/YqSUvgbdh1gH/E013dqKPLgwdYuXnQdZ/wBkRLR60TsjYXvCK/xfnZY0BSkMSA +QEWkyesKr/nq2oJB8BYIns4npppmgmvaiTl0VMhHmrY5d1JYgHQ9Sgg41zLNtBR/ +RKU5L64/AgMBAAECggEAYi9ge3JscVZPw6WXd6jN/5jOfOpu844INfeZoDz3dcbN +u2D2+LWsVh/iq96/XJzTLKV4YGy5U97ehkUrFA+5MFXyN02CreSmJ93m+f8T5F64 +uDuJV57O3BTsvvNmOtfsCz5isnUJGGmJnR+9KYuOgSMytPQnInXEkN2huJMO0Ta6 +5x/rVzKnP+NWfXaUtCmaNgY+uJLk7BlrT6jcL/munR7Llffhw1l1TApIKV61U7Te +bGybB/thdXU1JfvkWHMMGBH9wF4FvRJ+WIE542aYuTi57HJ+jgJhL0y0Izvp42On +16L3AgZ4E7J3cafb5s52wB1Hf8qtApo7PRWoJQltRQKBgQD/wDDYdvXGcRBQUWH+ +mCJm6OV82xvBp2mKGPAXwM8cz3VI0eqgeDN4VFzaRbyuoG8mvDIxLAKaSrXAhCF9 +eP1m6zh45MGVw6Hb7unJOP0Hs1/mT2Yg6OD+JftlW8DrhjU2rJzrAB4cvYM2Mlp+ +z2jZyTsH5gclqzwu34Hhz8PsqwKBgQD69eF0bsdOTKM3nP/cFRdr8SG1KP6UXNT2 +0okzHKj+QhYQRGtULEe3PWJtYHYo5elhmqOpcxy4djt4HefdauOIvB6RwQfiNwkq +x0ERH9W5ZSw/LxuOuMUNAaAJ4osyymb1o5gLrMdwS1oVVaTF3SS78mZpVs5Ekez+ +c88t5HXcvQKBgBge4zx3M8TsgvJgSpK9fHkiPAqji6GfDXgl0/cZiy8XbeNZUPyj +eY8+vackbqA1p2YK190FXpV4uF2Y2KPB1nxvcNsOECf01H4usUP2KP8h7siE8ofm +DtpJcMVlevN7q+clLoOHdk+VnBtvclOFckkgDn43NrNZzApLsC9A7iSTAoGADlY9 +qwkpGbAHIwY1F72cuO3tnwvYf2FOSUt9yw24Gc5stEE0YHqnHjDDjrwUBAIecxUC +hIuu+FrIyvPqaxvQI9+bX3hHmwTJ4UfAz9mhvBWrkXB/gofLuhJ9shLfIOevOhk+ +dmxIeIHVg6KA50za7GHMt/fdkM1FXMQA8f47PYECgYEAnT147gCKbCQlWyE1Q6Q3 +LgtGCNbmEW4gPpZnMIDiwBZBqfX2fdQUZhBbANEgx98Dy7fzL18y+ULhqQAHlZmv +wj42J35Ni2CCVVh58j2OQBmjhRnuVtbeDkWfF6lrwpdiAS85MZgTSnSrnj3opgx1 +m+jMknsSIITKIhu6oa1PqvM= +-----END PRIVATE KEY----- diff --git a/core/archipelago/src/appgate/testdata/other.key b/core/archipelago/src/appgate/testdata/other.key new file mode 100644 index 00000000..e4334007 --- /dev/null +++ b/core/archipelago/src/appgate/testdata/other.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCy2KgVkOYSz0QO +QxXA0ENomMr0Butuh4Yv5KT9RzrTxrsf/GfiJPX5fjtANwUXniojMNClxLGGep5v +55Sy0wXgj1HHX00eeWfMIW3A7pYKy2geM3gY3/Xull7Ny2A1+aa4XzK9jIZXqkLj +zSd+zdkkrxa0JTdv/kVFX198pvx5w79KBx706NLgY8T6YqAIerturvwclL3uWvYm +kB2CirwYAR4XO+6RxAqe+msjxn877h5bUSxwtfL7OzdcuyilGBG2FeB9FSm7r7h2 +zLJcch3WCwHBWbLK6n5pprrYXLFgTJjC/VlNjGmv9ZAG7HPnAw9J0Ck+JT3s+7mf +pWiNzPePAgMBAAECggEANcLsEAONLcVRY2omHV5djRE1HRMBbanenAIC2MIzPFsG +gDB7N989c8DO5dhENxvL9eUkK1iLtu2gN+po6DKIFz9t6V1MDOeY3KOF3xO5Vchc +ZYu6Q9v7DTv1hq5mnwMLa2vukE0wSyT604iloTgW2LCrRf7UAd3xC9AGH64Awkcl +TxWeuXDf1Z9ndTXwTcyWJwxs69eDhxHJdNi8Pit0sowuQJMsmj+uxWsAXb5DvmHV +HxihzZ8tQpq7ZCuJBcpqcYZ3/XYxfYcGez42+1nIUHtcIaywQCZUk3WmL3wxEMRA +N5LoJuI1a6EYNRZdtwmD3aoNwOapPSIeIyf1AuVV8QKBgQDZABBmxMecLq1sYYjG +2vaS2aHtg4qaeoQV97vkbOceNHX54gCi/Oj6ocm+jKDoNG0LRITBTMc0fivpccUu +dNnW7niTQFUqQ3XS7ONMUbMZNUaiiYaQu2Pzsvq+FVDbLD0VVIqd4mQFNY8wOAMi +VImPvFUuV2tBW9Od/bZTAIP4kQKBgQDS/SxRc7NJ7sb8D6LKQcUN3RQ6/Yi9caBN ++PbC7rLALM8CIFStiSTVH0jO1aEwLoNSlOG7IBLOPaVxp3sauqs2VHHLrPS3ter0 +UQt5WDdsgNtJVAZ9GKw10pZ5EQJHTxDVIyFAyOpkLm1DdUsRCShheW5HaFRGrYhA +XV3hYxL+HwKBgFGNepyE29fQmxCeXz8Mz5pE/Fw9EXwZC0cOQakJXJq3cJcm3sJi +dlSrNRzN0TMzcL/JUnMrHbqWqH4lacuZ0ry6BsqgZOFrVP6eVJY8JikVIqS3NsFy +C5Bs9Vs2u5qDN7mqeiX4DUr/4/5lLphaWRCR4Rl3dTGtBwzbawgqq25hAoGAEQOz +oDnpWmv0Bf2ozhCxuGV8rSkm7sgL+l26YIvpRFAYvX4n9fqaSsmEEJHvtrf5hR5W +ecWjXphgECNGbShiiDYVGyyua2YzNVKXz0hK5+gYRviMsWfc81YxJkA149Q/ckCr +/NJ2/G82Bnud+xi29e1Z9E44hZ6W30HoQTXBIVcCgYApBXtQzue+jSRZXhpgw+ps +9H7eTHsA6zsxtqk4O/tijkkcsv+LepJ81nJNN8G4aqbdAb132w5bHqh9ir0DFtKj +2Eqae15OFYKfYV83TOAcc/IW3aZi8jkNyux08k43gIn3Lzo5T09jUSFFV5FazVNi +RxnrHeKUcS43Z346QXYrsg== +-----END PRIVATE KEY----- diff --git a/core/archipelago/src/appgate/tls.rs b/core/archipelago/src/appgate/tls.rs new file mode 100644 index 00000000..c0131f70 --- /dev/null +++ b/core/archipelago/src/appgate/tls.rs @@ -0,0 +1,393 @@ +//! TLS for gated app ports, alongside plain HTTP on the same socket. +//! +//! # Why both, on one port +//! +//! An app port has to serve whatever the browser asks for. A node whose +//! dashboard is plain HTTP embeds `http://host:PORT`; a node with HTTPS embeds +//! `https://host:PORT` — and an HTTPS page cannot embed an HTTP frame at all +//! (mixed content), so the choice is genuinely per-node, not per-fleet. Giving +//! TLS its own port number would mean every app declares a second port, every +//! manifest changes, and torrc doubles. Instead the gate peeks the first byte: +//! a TLS ClientHello starts with `0x16` (handshake) and no HTTP method does, so +//! the two are distinguishable without consuming anything. +//! +//! `peek` is what makes this safe — it leaves the bytes in the socket buffer, +//! so the TLS acceptor still sees a complete, untouched ClientHello. +//! +//! # Why reload, rather than load once +//! +//! `scripts/setup-node-ca.sh` reissues the leaf whenever the node gains an +//! address (DHCP, Tailscale coming up, the fips0 ULA appearing late) — the same +//! churn the bind sweep exists for. A config parsed once at startup would keep +//! serving a certificate that omits the address the user is actually on, and +//! the failure is a browser-side name mismatch that no node-side log would +//! explain. So the mtime of both files is checked and the config rebuilt when +//! either moves. +//! +//! # Absent certificates are not an error +//! +//! A node that has never run the CA script has no certificate. That node serves +//! plain HTTP exactly as before and is fully functional — TLS is an upgrade, +//! not a requirement — so a missing file is logged once at debug, not warn. +//! What IS logged at warn is a certificate that exists but cannot be parsed: +//! that is a misconfiguration the operator can act on, and silently falling +//! back to plain HTTP would hide it. + +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::SystemTime; + +use tokio::sync::RwLock; +use tokio_rustls::rustls::{Certificate, PrivateKey, ServerConfig}; +use tokio_rustls::TlsAcceptor; +use tracing::{debug, warn}; + +/// Where `setup-node-ca.sh` writes the node's leaf. Same pair nginx serves, so +/// the dashboard and the app ports present one identity and a single trusted +/// CA covers both. +const DEFAULT_CERT: &str = "/etc/archipelago/ssl/archipelago.crt"; +const DEFAULT_KEY: &str = "/etc/archipelago/ssl/archipelago.key"; + +/// First byte of a TLS record of type `handshake` (22). No HTTP request can +/// begin with it: methods are uppercase ASCII letters, so the two wire formats +/// are unambiguous from a single byte. +pub const TLS_HANDSHAKE_FIRST_BYTE: u8 = 0x16; + +/// Does this look like the start of a TLS connection rather than plain HTTP? +pub fn looks_like_tls(first: u8) -> bool { + first == TLS_HANDSHAKE_FIRST_BYTE +} + +/// Lazily-built, mtime-invalidated TLS config for the gate. +pub struct GateTls { + cert_path: PathBuf, + key_path: PathBuf, + cached: RwLock>, +} + +struct Cached { + acceptor: TlsAcceptor, + stamp: Stamp, +} + +/// Modification times of both halves. Compared as a pair because reissuing +/// writes the certificate and the key separately — keying on only one would +/// serve a certificate that no longer matches its key. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +struct Stamp { + cert: SystemTime, + key: SystemTime, +} + +impl GateTls { + pub fn new() -> Self { + Self::with_paths(DEFAULT_CERT, DEFAULT_KEY) + } + + pub fn with_paths(cert: impl Into, key: impl Into) -> Self { + Self { + cert_path: cert.into(), + key_path: key.into(), + cached: RwLock::new(None), + } + } + + /// The current acceptor, rebuilding it if the files changed underneath. + /// + /// `None` means this node has no usable certificate and app ports stay + /// plain HTTP. Callers must treat that as ordinary, not as a failure. + pub async fn acceptor(&self) -> Option { + let stamp = self.stamp().await?; + + if let Some(c) = self.cached.read().await.as_ref() { + if c.stamp == stamp { + return Some(c.acceptor.clone()); + } + } + + // Rebuild. Re-check under the write lock so concurrent connections + // during a reissue do not each parse the same files. + let mut guard = self.cached.write().await; + if let Some(c) = guard.as_ref() { + if c.stamp == stamp { + return Some(c.acceptor.clone()); + } + } + + match load_config(&self.cert_path, &self.key_path).await { + Ok(config) => { + let acceptor = TlsAcceptor::from(Arc::new(config)); + debug!( + cert = %self.cert_path.display(), + "app gate loaded its TLS certificate" + ); + *guard = Some(Cached { + acceptor: acceptor.clone(), + stamp, + }); + Some(acceptor) + } + Err(e) => { + // A present-but-broken certificate is an operator-actionable + // misconfiguration; do not let it pass quietly as "no TLS". + warn!( + cert = %self.cert_path.display(), + error = %e, + "app gate could not load its TLS certificate — app ports stay plain HTTP" + ); + // Cache the failure against this stamp so a broken file is not + // re-parsed on every single connection. + *guard = None; + None + } + } + } + + async fn stamp(&self) -> Option { + let cert = mtime(&self.cert_path).await?; + let key = mtime(&self.key_path).await?; + Some(Stamp { cert, key }) + } +} + +impl Default for GateTls { + fn default() -> Self { + Self::new() + } +} + +async fn mtime(path: &Path) -> Option { + tokio::fs::metadata(path).await.ok()?.modified().ok() +} + +async fn load_config(cert_path: &Path, key_path: &Path) -> io::Result { + let cert_pem = tokio::fs::read(cert_path).await?; + let key_pem = tokio::fs::read(key_path).await?; + build_config(&cert_pem, &key_pem) +} + +/// Split out from the filesystem so it can be tested against bytes directly. +pub(crate) fn build_config(cert_pem: &[u8], key_pem: &[u8]) -> io::Result { + let certs: Vec = rustls_pemfile::certs(&mut &cert_pem[..])? + .into_iter() + .map(Certificate) + .collect(); + if certs.is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "no certificates in PEM", + )); + } + + let key = read_key(key_pem)?; + + // rustls does NOT check that the key matches the certificate — verified by + // test, not assumed: `with_single_cert` accepts a pair from two different + // keys and only fails later, mid-handshake, in someone's browser. That is + // precisely the silently-broken-security-control shape this module exists + // to avoid, so prove the pairing here and refuse to serve otherwise. + ensure_key_matches_cert(&certs[0], &key)?; + + ServerConfig::builder() + .with_safe_defaults() + .with_no_client_auth() + .with_single_cert(certs, key) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e)) +} + +/// Sign a fixed message with the private key and verify it with the public key +/// inside the certificate. They pair iff the verification succeeds. +fn ensure_key_matches_cert(cert: &Certificate, key: &PrivateKey) -> io::Result<()> { + use tokio_rustls::rustls::sign; + + let signing_key = sign::any_supported_type(key) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "unsupported private key type"))?; + + // Any scheme the key supports will do — this proves possession, it is not + // negotiating anything. Offer the full set and let rustls pick. + const ALL_SCHEMES: &[tokio_rustls::rustls::SignatureScheme] = { + use tokio_rustls::rustls::SignatureScheme as S; + &[ + S::ECDSA_NISTP256_SHA256, + S::ECDSA_NISTP384_SHA384, + S::ED25519, + S::RSA_PSS_SHA256, + S::RSA_PSS_SHA384, + S::RSA_PSS_SHA512, + S::RSA_PKCS1_SHA256, + S::RSA_PKCS1_SHA384, + S::RSA_PKCS1_SHA512, + ] + }; + let signer = signing_key + .choose_scheme(ALL_SCHEMES) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "no usable signature scheme"))?; + + const PROOF: &[u8] = b"archipelago app gate certificate pairing check"; + let signature = signer + .sign(PROOF) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + + let end_entity = webpki::EndEntityCert::try_from(cert.0.as_slice()) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, format!("bad certificate: {e}")))?; + + let alg: &webpki::SignatureAlgorithm = match signer.scheme() { + tokio_rustls::rustls::SignatureScheme::RSA_PKCS1_SHA256 => { + &webpki::RSA_PKCS1_2048_8192_SHA256 + } + tokio_rustls::rustls::SignatureScheme::RSA_PKCS1_SHA384 => { + &webpki::RSA_PKCS1_2048_8192_SHA384 + } + tokio_rustls::rustls::SignatureScheme::RSA_PKCS1_SHA512 => { + &webpki::RSA_PKCS1_2048_8192_SHA512 + } + tokio_rustls::rustls::SignatureScheme::RSA_PSS_SHA256 => { + &webpki::RSA_PSS_2048_8192_SHA256_LEGACY_KEY + } + tokio_rustls::rustls::SignatureScheme::RSA_PSS_SHA384 => { + &webpki::RSA_PSS_2048_8192_SHA384_LEGACY_KEY + } + tokio_rustls::rustls::SignatureScheme::RSA_PSS_SHA512 => { + &webpki::RSA_PSS_2048_8192_SHA512_LEGACY_KEY + } + tokio_rustls::rustls::SignatureScheme::ECDSA_NISTP256_SHA256 => &webpki::ECDSA_P256_SHA256, + tokio_rustls::rustls::SignatureScheme::ECDSA_NISTP384_SHA384 => &webpki::ECDSA_P384_SHA384, + tokio_rustls::rustls::SignatureScheme::ED25519 => &webpki::ED25519, + // An unrecognised scheme must not silently skip the check. + other => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("cannot verify key/certificate pairing for scheme {other:?}"), + )) + } + }; + + end_entity + .verify_signature(alg, PROOF, &signature) + .map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "private key does not match the certificate", + ) + }) +} + +/// Accept PKCS#8 or PKCS#1. `setup-node-ca.sh` emits PKCS#8, but a key that +/// predates it (or was generated by hand) may be PKCS#1, and refusing that +/// would be a silent downgrade to plain HTTP on an already-working node. +fn read_key(key_pem: &[u8]) -> io::Result { + if let Some(k) = rustls_pemfile::pkcs8_private_keys(&mut &key_pem[..])? + .into_iter() + .next() + { + return Ok(PrivateKey(k)); + } + if let Some(k) = rustls_pemfile::rsa_private_keys(&mut &key_pem[..])? + .into_iter() + .next() + { + return Ok(PrivateKey(k)); + } + Err(io::Error::new( + io::ErrorKind::InvalidData, + "no PKCS#8 or PKCS#1 private key in PEM", + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + // Generated by scripts/setup-node-ca.sh's own openssl invocation, so these + // exercise the exact shape the node produces. + const CERT: &[u8] = include_bytes!("testdata/leaf.crt"); + const KEY: &[u8] = include_bytes!("testdata/leaf.key"); + + #[test] + fn a_tls_client_hello_is_distinguishable_from_every_http_method() { + assert!(looks_like_tls(0x16)); + // Every HTTP method starts with an uppercase letter; none is 0x16. + for m in ["GET", "POST", "PUT", "HEAD", "OPTIONS", "DELETE", "PATCH"] { + assert!( + !looks_like_tls(m.as_bytes()[0]), + "{m} misread as a TLS handshake" + ); + } + } + + #[test] + fn builds_a_config_from_the_nodes_own_cert_and_key() { + assert!(build_config(CERT, KEY).is_ok()); + } + + #[test] + fn a_cert_without_its_matching_key_is_rejected_not_ignored() { + // Key from a different pair: rustls must refuse rather than serve a + // certificate it cannot prove ownership of. + let other = build_config(CERT, OTHER_KEY); + assert!(other.is_err(), "mismatched cert/key pair was accepted"); + } + const OTHER_KEY: &[u8] = include_bytes!("testdata/other.key"); + + #[test] + fn empty_pem_is_an_error_rather_than_an_empty_chain() { + assert!(build_config(b"", KEY).is_err()); + assert!(build_config(CERT, b"").is_err()); + } + + #[tokio::test] + async fn a_node_without_certificates_reports_no_acceptor() { + let tls = GateTls::with_paths( + "/nonexistent/archipelago.crt", + "/nonexistent/archipelago.key", + ); + assert!(tls.acceptor().await.is_none()); + } + + #[tokio::test] + async fn an_acceptor_is_built_and_then_served_from_cache() { + let dir = tempfile::tempdir().unwrap(); + let cert = dir.path().join("c.crt"); + let key = dir.path().join("c.key"); + tokio::fs::write(&cert, CERT).await.unwrap(); + tokio::fs::write(&key, KEY).await.unwrap(); + + let tls = GateTls::with_paths(&cert, &key); + assert!(tls.acceptor().await.is_some()); + // Second call hits the cache; the observable contract is simply that it + // still yields an acceptor. + assert!(tls.acceptor().await.is_some()); + } + + #[tokio::test] + async fn a_reissued_certificate_is_picked_up_without_a_restart() { + let dir = tempfile::tempdir().unwrap(); + let cert = dir.path().join("c.crt"); + let key = dir.path().join("c.key"); + tokio::fs::write(&cert, CERT).await.unwrap(); + tokio::fs::write(&key, KEY).await.unwrap(); + + let tls = GateTls::with_paths(&cert, &key); + assert!(tls.acceptor().await.is_some()); + let first = *tls.cached.read().await.as_ref().map(|c| &c.stamp).unwrap(); + + // Reissue with a distinctly later mtime, the way the CA script does + // when the node gains an address. Set explicitly rather than relying on + // wall-clock advancing, because a same-second rewrite can land on an + // identical mtime on coarse-granularity filesystems and make this pass + // or fail by luck. + tokio::fs::write(&cert, CERT).await.unwrap(); + let later = SystemTime::now() + std::time::Duration::from_secs(5); + std::fs::File::options() + .write(true) + .open(&cert) + .unwrap() + .set_modified(later) + .unwrap(); + + assert!(tls.acceptor().await.is_some()); + let second = *tls.cached.read().await.as_ref().map(|c| &c.stamp).unwrap(); + assert_ne!(first, second, "reissued certificate was not reloaded"); + } +}