diff --git a/image-recipe/configs/nginx-archipelago.conf b/image-recipe/configs/nginx-archipelago.conf index 72e55eeb..c84cd389 100644 --- a/image-recipe/configs/nginx-archipelago.conf +++ b/image-recipe/configs/nginx-archipelago.conf @@ -18,6 +18,17 @@ server { root /opt/archipelago/web-ui; 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 add_header X-Content-Type-Options "nosniff" always; add_header X-Frame-Options "SAMEORIGIN" always; @@ -934,6 +945,13 @@ server { index index.html; 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 add_header X-Content-Type-Options "nosniff" always; add_header X-Frame-Options "SAMEORIGIN" always; diff --git a/neode-ui/src/views/settings/NodeCertificateSection.vue b/neode-ui/src/views/settings/NodeCertificateSection.vue new file mode 100644 index 00000000..71554dd0 --- /dev/null +++ b/neode-ui/src/views/settings/NodeCertificateSection.vue @@ -0,0 +1,130 @@ + + + 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 <