Add CA download to dashboard: GET /gateway/ca.crt + Gateway-panel button

When the gateway serves internal-CA HTTPS (gateway.tls=internal), other devices
must trust Caddy's root CA to accept *.lan HTTPS. Surface that from the dashboard
instead of hand-copying root.crt around.

- API: GET /gateway/ca.crt returns the public root cert (PEM) as a download,
  sourced from Caddy's admin API (/pki/ca/local — matches the running gateway)
  with an on-disk root.crt fallback. 404 unless tls=internal. GatewayInfo gains
  `tls` and `ca_fingerprint` (SHA-256, for out-of-band verification). The CA
  private key is never exposed — only the public root.
- UI: a "CA cert" button in the Gateway panel, shown only when tls=internal,
  linking to the download with the fingerprint in its tooltip.
- Tests for the tls=off defaults and the 404-without-internal-tls path; docs note
  the button + endpoint.
This commit is contained in:
2026-06-29 08:26:15 -07:00
parent 0d9c1ae6c2
commit fc5802d823
6 changed files with 116 additions and 5 deletions

View File

@@ -1,8 +1,9 @@
import { useMemo, useState } from "react"
import { Link } from "react-router-dom"
import { Globe, RefreshCw, FileText } from "lucide-react"
import { Globe, RefreshCw, FileText, ShieldCheck } from "lucide-react"
import type { GatewayInfo, HealthStatus } from "@/types"
import { useGatewayReload, useCaddyfile } from "@/services/api/hooks"
import { apiClient } from "@/services/api/client"
import { HealthBadge } from "./HealthBadge"
interface GatewayPanelProps {
@@ -33,6 +34,20 @@ export function GatewayPanel({ gateway, statuses }: GatewayPanelProps) {
</span>
</div>
<div className="flex items-center gap-1">
{gateway.tls === "internal" && (
<a
href={apiClient.streamUrl("/gateway/ca.crt")}
download="castle-root.crt"
className="flex items-center gap-1 text-xs px-2.5 py-1 rounded bg-[var(--border)] hover:bg-[var(--border)]/80 text-[var(--muted)] hover:text-[var(--foreground)] transition-colors"
title={
"Download the gateway's root CA — install on other devices to trust *.lan HTTPS" +
(gateway.ca_fingerprint ? `\nSHA-256: ${gateway.ca_fingerprint}` : "")
}
>
<ShieldCheck size={12} />
CA cert
</a>
)}
<button
onClick={() => reload()}
disabled={reloading}

View File

@@ -124,6 +124,8 @@ export interface GatewayInfo {
service_count: number
managed_count: number
routes: GatewayRoute[]
tls?: string | null // "internal" → host routes served over HTTPS by Caddy's local CA
ca_fingerprint?: string | null // SHA-256 of the downloadable root CA
}
export interface ServiceActionResponse {

View File

@@ -156,6 +156,11 @@ class GatewayInfo(BaseModel):
service_count: int
managed_count: int
routes: list[GatewayRoute] = []
# TLS mode (None/"off" → HTTP-only; "internal" → Caddy local-CA HTTPS for host
# routes). When "internal", ca_fingerprint is the SHA-256 of the root CA the
# dashboard offers for download so clients can trust *.lan HTTPS.
tls: str | None = None
ca_fingerprint: str | None = None
class NodeSummary(BaseModel):

View File

@@ -3,10 +3,15 @@
from __future__ import annotations
import asyncio
import hashlib
import json
import os
import shutil
import ssl
import urllib.request
from pathlib import Path
from fastapi import APIRouter, HTTPException, status
from fastapi import APIRouter, HTTPException, Response, status
from castle_core.config import SPECS_DIR
from castle_core.generators.caddyfile import generate_caddyfile_from_registry
@@ -894,6 +899,13 @@ def get_gateway() -> GatewayInfo:
# Caddyfile order is precedence-sensitive; the displayed table is alphabetical.
routes.sort(key=lambda r: r.address)
tls = registry.node.gateway_tls
ca_fingerprint = None
if (tls or "").lower() == "internal":
pem = _gateway_ca_pem(registry.node.gateway_tls)
if pem:
ca_fingerprint = _ca_fingerprint(pem)
return GatewayInfo(
port=registry.node.gateway_port,
hostname=registry.node.hostname,
@@ -901,6 +913,8 @@ def get_gateway() -> GatewayInfo:
service_count=service_count,
managed_count=managed_count,
routes=routes,
tls=tls,
ca_fingerprint=ca_fingerprint,
)
@@ -911,6 +925,66 @@ def get_caddyfile() -> dict[str, str]:
return {"content": generate_caddyfile_from_registry(registry)}
# Caddy's admin API exposes the local CA's root cert — the authoritative source,
# matching the running gateway (same endpoint `caddy trust` uses).
_CADDY_ADMIN = "http://localhost:2019"
def _gateway_ca_pem(gateway_tls: str | None) -> str | None:
"""The gateway's local-CA root certificate (PEM), or None.
Only the public root cert — never the CA private key. Returns None unless
`gateway.tls` is "internal" and a CA exists. Prefers Caddy's admin API (the
running gateway's actual CA); falls back to the on-disk root.crt.
"""
if (gateway_tls or "").lower() != "internal":
return None
try:
with urllib.request.urlopen(f"{_CADDY_ADMIN}/pki/ca/local", timeout=2) as resp:
data = json.loads(resp.read())
pem = data.get("root_certificate")
if pem:
return pem
except Exception:
pass # admin API down → try the file
base = os.environ.get("XDG_DATA_HOME") or str(Path.home() / ".local" / "share")
root = Path(base) / "caddy" / "pki" / "authorities" / "local" / "root.crt"
if root.is_file():
return root.read_text()
return None
def _ca_fingerprint(pem: str) -> str | None:
"""Colon-separated uppercase SHA-256 of the cert DER, for out-of-band verify."""
try:
der = ssl.PEM_cert_to_DER_cert(pem)
except Exception:
return None
hexd = hashlib.sha256(der).hexdigest().upper()
return ":".join(hexd[i : i + 2] for i in range(0, len(hexd), 2))
@router.get("/gateway/ca.crt")
def get_gateway_ca() -> Response:
"""Download the gateway's local-CA root cert so clients can trust *.lan HTTPS.
Public certificate only (the CA private key never leaves the host). 404 when
the gateway isn't serving internal-CA TLS or no cert has been issued yet.
"""
registry = get_registry()
pem = _gateway_ca_pem(registry.node.gateway_tls)
if not pem:
raise HTTPException(
status_code=404,
detail="No gateway CA (gateway.tls is not 'internal', or no cert yet).",
)
return Response(
content=pem,
media_type="application/x-x509-ca-cert",
headers={"Content-Disposition": 'attachment; filename="castle-root.crt"'},
)
@router.post("/gateway/reload")
async def reload_gateway() -> dict[str, str]:
"""Regenerate Caddyfile and reload Caddy."""

View File

@@ -269,6 +269,17 @@ class TestGateway:
for r in data["routes"]:
assert r["kind"] in ("static", "proxy", "remote")
def test_gateway_tls_off_by_default(self, client: TestClient) -> None:
"""No TLS configured → tls/ca_fingerprint are null (HTTP-only gateway)."""
data = client.get("/gateway").json()
assert data["tls"] is None
assert data["ca_fingerprint"] is None
def test_gateway_ca_404_without_internal_tls(self, client: TestClient) -> None:
"""The CA download is unavailable unless gateway.tls is 'internal'."""
response = client.get("/gateway/ca.crt")
assert response.status_code == 404
class TestConfigEditor:
"""Virtual castle.yaml aggregation/scatter endpoints."""

View File

@@ -355,9 +355,13 @@ Two operational requirements:
lower the floor once: `net.ipv4.ip_unprivileged_port_start=80` (persist in
`/etc/sysctl.d/`). This beats `setcap`, which `NoNewPrivileges=true` would void.
- **Trust the local CA.** Run `caddy trust` on the gateway host, then distribute
`~/.local/share/caddy/pki/authorities/local/root.crt` to every other box's
system/browser trust store — `.lan` can't get a public cert, so clients trust
Caddy's root instead. (Firefox uses its own store; import it there too.)
the root CA to every other box's system/browser trust store — `.lan` can't get
a public cert, so clients trust Caddy's root instead. (Firefox uses its own
store; import it there too.) The dashboard's Gateway panel has a **CA cert**
download button (only shown when `tls: internal`), backed by
`GET /gateway/ca.crt` — the public root cert, sourced from Caddy's admin API,
with its SHA-256 shown for out-of-band verification. The on-disk copy is at
`~/.local/share/caddy/pki/authorities/local/root.crt`.
Routing only moves bytes — it does **not** supply the proxied app's own auth.
If a backend requires a token/credential (e.g. in the URL or a header), that