feat(tls): per-node certificate authority + Settings install flow
Demo images / Build & push demo images (push) Failing after 2m19s
Demo images / Build & push demo images (push) Failing after 2m19s
The node served a bare self-signed leaf, so a browser exception had to be granted per ORIGIN — scheme + host + port. The dashboard on :443 and an app on :8334 are different origins, and a certificate interstitial CANNOT be accepted inside an iframe, so a gated app embedded over HTTPS could never render no matter how many warnings the user clicked through. (Mixed content blocks the plain-HTTP variant first, before the SameSite cookie question the symptom was originally filed under.) A CA fixes it structurally: ports are not part of a certificate's identity, so one leaf with the right SANs covers every port on the host, and one installed CA trusts them all. - scripts/setup-node-ca.sh generates the CA (4096-bit, pathlen:0, keyCertSign only) and issues a 397-day leaf covering archipelago.local, the hostname, the Tailscale MagicDNS name and every global address the host holds. Idempotent — re-running reuses the CA and only reissues the leaf, so gaining an address does not invalidate copies users already installed. --force-ca is the deliberate escape hatch and says what it costs. - nginx serves the public CA at /ca.crt on both schemes, unauthenticated by design: a device fetches it before it can validate the node, so gating it behind HTTPS or a login would be a chicken-and-egg. - Settings → System shows the fingerprint and per-platform install steps. crypto.subtle does not exist outside a secure context — precisely the case this feature exists to fix — so an HTTP dashboard gets the openssl command to verify by hand instead of a blank field. Verified locally: chain validates, key pairs with the leaf, CA:TRUE/CA:FALSE are correct, keys are 0600. Two TLS servers on different ports both verify (ssl_verify_result=0) against the CA alone and are rejected without it — the one-CA-covers-every-port claim, tested rather than assumed. Not yet wired: app ports still serve plain HTTP. Putting TLS on them is the next step and is what actually closes the iframe-login bug. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
c65ee03a5c
commit
aab74127f5
@@ -18,6 +18,17 @@ server {
|
|||||||
root /opt/archipelago/web-ui;
|
root /opt/archipelago/web-ui;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
# This node's CA, for devices that have not trusted it yet. Deliberately
|
||||||
|
# unauthenticated and served over plain HTTP: a device fetches this BEFORE
|
||||||
|
# it can validate the node's own certificate, so requiring HTTPS or a login
|
||||||
|
# here would be a chicken-and-egg. It is a public certificate — never a key
|
||||||
|
# — and the dashboard shows its fingerprint so it can be checked on sight.
|
||||||
|
location = /ca.crt {
|
||||||
|
alias /etc/archipelago/ssl/ca-download.crt;
|
||||||
|
default_type application/x-x509-ca-cert;
|
||||||
|
add_header Content-Disposition 'attachment; filename="archipelago-node-ca.crt"';
|
||||||
|
}
|
||||||
|
|
||||||
# Security headers
|
# Security headers
|
||||||
add_header X-Content-Type-Options "nosniff" always;
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
@@ -934,6 +945,13 @@ server {
|
|||||||
index index.html;
|
index index.html;
|
||||||
include snippets/archipelago-pwa.conf;
|
include snippets/archipelago-pwa.conf;
|
||||||
|
|
||||||
|
# Same CA download over HTTPS — see the note in the HTTP block above.
|
||||||
|
location = /ca.crt {
|
||||||
|
alias /etc/archipelago/ssl/ca-download.crt;
|
||||||
|
default_type application/x-x509-ca-cert;
|
||||||
|
add_header Content-Disposition 'attachment; filename="archipelago-node-ca.crt"';
|
||||||
|
}
|
||||||
|
|
||||||
# Security headers
|
# Security headers
|
||||||
add_header X-Content-Type-Options "nosniff" always;
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
|
||||||
|
// This node signs its own certificates with a CA that never leaves it. Install
|
||||||
|
// that CA once per device and every port on this node is trusted — which is what
|
||||||
|
// lets a gated app load inside the dashboard's frame at all: a cert warning
|
||||||
|
// cannot be clicked through inside an iframe, so an untrusted app port simply
|
||||||
|
// fails to render.
|
||||||
|
|
||||||
|
const fingerprint = ref('')
|
||||||
|
const fingerprintError = ref('')
|
||||||
|
const loading = ref(true)
|
||||||
|
const caAvailable = ref(false)
|
||||||
|
|
||||||
|
// SHA-256 over the DER bytes — the same number `openssl x509 -fingerprint
|
||||||
|
// -sha256` prints, so the two can be compared character for character.
|
||||||
|
async function computeFingerprint(pem: string): Promise<string> {
|
||||||
|
const body = pem
|
||||||
|
.replace(/-----BEGIN CERTIFICATE-----/, '')
|
||||||
|
.replace(/-----END CERTIFICATE-----/, '')
|
||||||
|
.replace(/\s+/g, '')
|
||||||
|
const der = Uint8Array.from(atob(body), (c) => c.charCodeAt(0))
|
||||||
|
const digest = await crypto.subtle.digest('SHA-256', der)
|
||||||
|
return Array.from(new Uint8Array(digest))
|
||||||
|
.map((b) => b.toString(16).padStart(2, '0').toUpperCase())
|
||||||
|
.join(':')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/ca.crt', { cache: 'no-store' })
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
const pem = await res.text()
|
||||||
|
if (!pem.includes('BEGIN CERTIFICATE')) throw new Error('not a certificate')
|
||||||
|
caAvailable.value = true
|
||||||
|
|
||||||
|
// crypto.subtle only exists in a secure context. That is exactly the case
|
||||||
|
// this feature is meant to fix, so an HTTP dashboard lands here — say so
|
||||||
|
// and give the offline command rather than showing nothing.
|
||||||
|
if (!window.crypto?.subtle) {
|
||||||
|
fingerprintError.value =
|
||||||
|
'The fingerprint cannot be computed over a plain HTTP connection. Verify it on the node instead: openssl x509 -in /etc/archipelago/ssl/ca.crt -noout -fingerprint -sha256'
|
||||||
|
} else {
|
||||||
|
fingerprint.value = await computeFingerprint(pem)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
caAvailable.value = false
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mb-6">
|
||||||
|
<h3 class="text-base font-medium text-white/90 mb-1">Node certificate</h3>
|
||||||
|
<p class="text-sm text-white/60 mb-4">
|
||||||
|
Install this node's certificate on a device and it stops warning you about
|
||||||
|
this node — on every port, not just the dashboard. Apps that open inside
|
||||||
|
the dashboard need this: a certificate warning cannot be accepted inside an
|
||||||
|
embedded frame, so an untrusted app shows nothing at all.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-if="loading" class="text-sm text-white/50">Checking…</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else-if="!caAvailable"
|
||||||
|
class="p-3 bg-white/5 border border-white/10 rounded-lg text-sm text-white/70"
|
||||||
|
>
|
||||||
|
This node has not generated a certificate authority yet. Run
|
||||||
|
<code class="px-1 py-0.5 bg-black/30 rounded text-xs">scripts/setup-node-ca.sh</code>
|
||||||
|
on the node, then reload this page.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<a
|
||||||
|
href="/ca.crt"
|
||||||
|
download="archipelago-node-ca.crt"
|
||||||
|
class="inline-flex items-center gap-2 px-4 py-3 glass-button rounded-lg text-sm font-semibold"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||||
|
</svg>
|
||||||
|
Download this node's certificate
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-white/80 mb-1">Fingerprint (SHA-256)</p>
|
||||||
|
<p v-if="fingerprint" class="font-mono text-xs text-white/70 break-all select-all">{{ fingerprint }}</p>
|
||||||
|
<p v-else class="text-xs text-orange-300/80">{{ fingerprintError }}</p>
|
||||||
|
<p class="text-xs text-white/50 mt-2">
|
||||||
|
Check this matches the fingerprint the node itself prints before you trust
|
||||||
|
it. If they differ, something is intercepting the connection — do not install it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details class="group">
|
||||||
|
<summary class="cursor-pointer text-sm font-medium text-white/80 py-2">
|
||||||
|
How to install it
|
||||||
|
</summary>
|
||||||
|
<div class="mt-2 space-y-3 text-sm text-white/60">
|
||||||
|
<p><strong class="text-white/80">macOS</strong> — open the file, add it to the
|
||||||
|
<em>login</em> keychain, then find it in Keychain Access, open it, expand Trust
|
||||||
|
and set “When using this certificate” to <em>Always Trust</em>.</p>
|
||||||
|
<p><strong class="text-white/80">iOS / iPadOS</strong> — download it in Safari and
|
||||||
|
allow the profile, then Settings → General → VPN & Device Management to
|
||||||
|
install it, and finally Settings → General → About → Certificate Trust Settings
|
||||||
|
to switch it on. Both steps are required.</p>
|
||||||
|
<p><strong class="text-white/80">Windows</strong> — right-click → Install
|
||||||
|
Certificate → Local Machine → place it in <em>Trusted Root Certification
|
||||||
|
Authorities</em>.</p>
|
||||||
|
<p><strong class="text-white/80">Android</strong> — Settings → Security →
|
||||||
|
Encryption & credentials → Install a certificate → CA certificate.</p>
|
||||||
|
<p><strong class="text-white/80">Linux</strong> — copy to
|
||||||
|
<code class="px-1 py-0.5 bg-black/30 rounded text-xs">/usr/local/share/ca-certificates/</code>
|
||||||
|
and run <code class="px-1 py-0.5 bg-black/30 rounded text-xs">sudo update-ca-certificates</code>.
|
||||||
|
Firefox keeps its own store — add it under Settings → Privacy & Security →
|
||||||
|
View Certificates → Authorities.</p>
|
||||||
|
<p class="text-white/50">
|
||||||
|
You are trusting this node, not a company. The signing key stays on the node
|
||||||
|
and only ever signs this node's own address. Anyone who takes the node also
|
||||||
|
takes that key — remove the certificate from your devices if you retire it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -5,6 +5,7 @@ import ClaudeAuthSection from '@/views/settings/ClaudeAuthSection.vue'
|
|||||||
import AIDataAccessSection from '@/views/settings/AIDataAccessSection.vue'
|
import AIDataAccessSection from '@/views/settings/AIDataAccessSection.vue'
|
||||||
import WebhookSection from '@/views/settings/WebhookSection.vue'
|
import WebhookSection from '@/views/settings/WebhookSection.vue'
|
||||||
import TelemetrySection from '@/views/settings/TelemetrySection.vue'
|
import TelemetrySection from '@/views/settings/TelemetrySection.vue'
|
||||||
|
import NodeCertificateSection from '@/views/settings/NodeCertificateSection.vue'
|
||||||
import BackupSection from '@/views/settings/BackupSection.vue'
|
import BackupSection from '@/views/settings/BackupSection.vue'
|
||||||
import SystemDangerZone from '@/views/settings/SystemDangerZone.vue'
|
import SystemDangerZone from '@/views/settings/SystemDangerZone.vue'
|
||||||
</script>
|
</script>
|
||||||
@@ -16,6 +17,7 @@ import SystemDangerZone from '@/views/settings/SystemDangerZone.vue'
|
|||||||
<AIDataAccessSection />
|
<AIDataAccessSection />
|
||||||
<WebhookSection />
|
<WebhookSection />
|
||||||
<TelemetrySection />
|
<TelemetrySection />
|
||||||
|
<NodeCertificateSection />
|
||||||
<BackupSection />
|
<BackupSection />
|
||||||
<SystemDangerZone />
|
<SystemDangerZone />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Executable
+149
@@ -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" <<EOF
|
||||||
|
basicConstraints=CA:FALSE
|
||||||
|
keyUsage=critical,digitalSignature,keyEncipherment
|
||||||
|
extendedKeyUsage=serverAuth
|
||||||
|
subjectAltName=$SAN
|
||||||
|
EOF
|
||||||
|
|
||||||
|
openssl x509 -req -in "$TMP/leaf.csr" -CA "$CA_CRT" -CAkey "$CA_KEY" \
|
||||||
|
-CAcreateserial -CAserial "$CA_SRL" \
|
||||||
|
-out "$TMP/leaf.crt" -days "$LEAF_DAYS" -sha256 -extfile "$TMP/leaf.ext" 2>/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 <<EOF
|
||||||
|
|
||||||
|
Done. Install $CA_CRT on each device that should reach this node without warnings.
|
||||||
|
The dashboard serves it at /ca.crt (Settings → Node certificate).
|
||||||
|
Verify the fingerprint above matches what the dashboard shows before trusting it.
|
||||||
|
EOF
|
||||||
Reference in New Issue
Block a user