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 { useMemo, useState } from "react"
|
||||||
import { Link } from "react-router-dom"
|
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 type { GatewayInfo, HealthStatus } from "@/types"
|
||||||
import { useGatewayReload, useCaddyfile } from "@/services/api/hooks"
|
import { useGatewayReload, useCaddyfile } from "@/services/api/hooks"
|
||||||
|
import { apiClient } from "@/services/api/client"
|
||||||
import { HealthBadge } from "./HealthBadge"
|
import { HealthBadge } from "./HealthBadge"
|
||||||
|
|
||||||
interface GatewayPanelProps {
|
interface GatewayPanelProps {
|
||||||
@@ -33,6 +34,20 @@ export function GatewayPanel({ gateway, statuses }: GatewayPanelProps) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<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
|
<button
|
||||||
onClick={() => reload()}
|
onClick={() => reload()}
|
||||||
disabled={reloading}
|
disabled={reloading}
|
||||||
|
|||||||
@@ -124,6 +124,8 @@ export interface GatewayInfo {
|
|||||||
service_count: number
|
service_count: number
|
||||||
managed_count: number
|
managed_count: number
|
||||||
routes: GatewayRoute[]
|
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 {
|
export interface ServiceActionResponse {
|
||||||
|
|||||||
@@ -156,6 +156,11 @@ class GatewayInfo(BaseModel):
|
|||||||
service_count: int
|
service_count: int
|
||||||
managed_count: int
|
managed_count: int
|
||||||
routes: list[GatewayRoute] = []
|
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):
|
class NodeSummary(BaseModel):
|
||||||
|
|||||||
@@ -3,10 +3,15 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
|
import ssl
|
||||||
|
import urllib.request
|
||||||
from pathlib import Path
|
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.config import SPECS_DIR
|
||||||
from castle_core.generators.caddyfile import generate_caddyfile_from_registry
|
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.
|
# Caddyfile order is precedence-sensitive; the displayed table is alphabetical.
|
||||||
routes.sort(key=lambda r: r.address)
|
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(
|
return GatewayInfo(
|
||||||
port=registry.node.gateway_port,
|
port=registry.node.gateway_port,
|
||||||
hostname=registry.node.hostname,
|
hostname=registry.node.hostname,
|
||||||
@@ -901,6 +913,8 @@ def get_gateway() -> GatewayInfo:
|
|||||||
service_count=service_count,
|
service_count=service_count,
|
||||||
managed_count=managed_count,
|
managed_count=managed_count,
|
||||||
routes=routes,
|
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)}
|
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")
|
@router.post("/gateway/reload")
|
||||||
async def reload_gateway() -> dict[str, str]:
|
async def reload_gateway() -> dict[str, str]:
|
||||||
"""Regenerate Caddyfile and reload Caddy."""
|
"""Regenerate Caddyfile and reload Caddy."""
|
||||||
|
|||||||
@@ -269,6 +269,17 @@ class TestGateway:
|
|||||||
for r in data["routes"]:
|
for r in data["routes"]:
|
||||||
assert r["kind"] in ("static", "proxy", "remote")
|
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:
|
class TestConfigEditor:
|
||||||
"""Virtual castle.yaml aggregation/scatter endpoints."""
|
"""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
|
lower the floor once: `net.ipv4.ip_unprivileged_port_start=80` (persist in
|
||||||
`/etc/sysctl.d/`). This beats `setcap`, which `NoNewPrivileges=true` would void.
|
`/etc/sysctl.d/`). This beats `setcap`, which `NoNewPrivileges=true` would void.
|
||||||
- **Trust the local CA.** Run `caddy trust` on the gateway host, then distribute
|
- **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
|
the root CA to every other box's system/browser trust store — `.lan` can't get
|
||||||
system/browser trust store — `.lan` can't get a public cert, so clients trust
|
a public cert, so clients trust Caddy's root instead. (Firefox uses its own
|
||||||
Caddy's root instead. (Firefox uses its own store; import it there too.)
|
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.
|
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
|
If a backend requires a token/credential (e.g. in the URL or a header), that
|
||||||
|
|||||||
Reference in New Issue
Block a user