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:
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user