Merge remote-tracking branch 'origin/main' into secure-ntp
This commit is contained in:
@@ -348,8 +348,9 @@ exists. Enrolment is where it comes into being.
|
|||||||
`tpm-selftest.sh` capability check; step-ca + EAP-TLS bench.
|
`tpm-selftest.sh` capability check; step-ca + EAP-TLS bench.
|
||||||
2. **Broker — BUILT (2026-06-28): [`serving/enrol-broker/`](../serving/enrol-broker/).**
|
2. **Broker — BUILT (2026-06-28): [`serving/enrol-broker/`](../serving/enrol-broker/).**
|
||||||
Go, stdlib-only. Public unauth listener (`POST /request`, `GET /status/{id}`)
|
Go, stdlib-only. Public unauth listener (`POST /request`, `GET /status/{id}`)
|
||||||
behind the serving nginx `/enrol/`; admin **mTLS web UI** (`/`, `/pending`,
|
behind the serving nginx `/enrol/`; admin **mTLS JSON API** (`/pending`,
|
||||||
`/approve|deny/{id}`) on the mgmt network; wordlist fingerprint = `H(CSR-pubkey ‖
|
`/approve|deny/{id}`) on the mgmt network — driven by the fleet-manager passkey
|
||||||
|
SPA (the broker's own HTML console was retired 2026-07-11); wordlist fingerprint = `H(CSR-pubkey ‖
|
||||||
server-nonce)`; time-boxed in-memory store + per-IP rate limit; signs via
|
server-nonce)`; time-boxed in-memory store + per-IP rate limit; signs via
|
||||||
`step ca token`+`step ca sign` (broker holds the provisioner password) with a
|
`step ca token`+`step ca sign` (broker holds the provisioner password) with a
|
||||||
dev `mock` signer. **Fingerprint-only assurance.** Admin client CA is a
|
dev `mock` signer. **Fingerprint-only assurance.** Admin client CA is a
|
||||||
|
|||||||
@@ -77,9 +77,15 @@ func main() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("read host key %s: %v", *hostKey, err)
|
log.Fatalf("read host key %s: %v", *hostKey, err)
|
||||||
}
|
}
|
||||||
princ := splitPrincipals(*principals)
|
// Principals are the names admins connect by. Default to this host's
|
||||||
|
// (DHCP-assigned) hostname AND its FQDN <hostname>.<domain> derived from the
|
||||||
|
// DHCP search domain at runtime (never baked — invariant #1); any explicit
|
||||||
|
// -principals are added on top. Without the FQDN a `@cert-authority *.<domain>`
|
||||||
|
// line rejects the cert with "name is not a listed principal" on FQDN logins
|
||||||
|
// (bench-confirmed on comet-beacon-54, 2026-07-11).
|
||||||
|
princ := appendUnique(defaultPrincipals(), splitPrincipals(*principals)...)
|
||||||
if len(princ) == 0 {
|
if len(princ) == 0 {
|
||||||
princ = defaultPrincipals()
|
princ = []string{"localhost"}
|
||||||
}
|
}
|
||||||
keyID := ""
|
keyID := ""
|
||||||
if id.cert.Leaf != nil {
|
if id.cert.Leaf != nil {
|
||||||
@@ -175,12 +181,58 @@ func splitPrincipals(s string) []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// defaultPrincipals returns the names admins connect to this host by: the
|
||||||
|
// (DHCP-assigned) hostname and, when a DHCP search domain is known, the FQDN
|
||||||
|
// <hostname>.<domain>. The FQDN is derived at runtime (never baked — invariant
|
||||||
|
// #1), mirroring how the NTS dispatcher derives ntp.<domain>: an admin's
|
||||||
|
// `@cert-authority *.<domain>` line only matches the host if the FQDN is a cert
|
||||||
|
// principal, so a short-name-only cert fails FQDN logins.
|
||||||
func defaultPrincipals() []string {
|
func defaultPrincipals() []string {
|
||||||
h, err := os.Hostname()
|
h, err := os.Hostname()
|
||||||
if err != nil || h == "" {
|
if err != nil || h == "" {
|
||||||
return []string{"localhost"}
|
return nil
|
||||||
}
|
}
|
||||||
return []string{h}
|
out := []string{h}
|
||||||
|
if !strings.Contains(h, ".") {
|
||||||
|
if dom := dhcpDomain(); dom != "" {
|
||||||
|
out = append(out, h+"."+dom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolvConf is the file dhcpDomain reads (a var so tests can point elsewhere).
|
||||||
|
var resolvConf = "/etc/resolv.conf"
|
||||||
|
|
||||||
|
// dhcpDomain returns the first DHCP-provided search/domain from resolv.conf
|
||||||
|
// (NetworkManager writes it from DHCP option 15/119). Empty if none is set.
|
||||||
|
func dhcpDomain() string {
|
||||||
|
b, err := os.ReadFile(resolvConf)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for ln := range strings.SplitSeq(string(b), "\n") {
|
||||||
|
f := strings.Fields(ln)
|
||||||
|
if len(f) >= 2 && (f[0] == "search" || f[0] == "domain") {
|
||||||
|
return f[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// appendUnique appends to base each of extra not already present (order-preserving).
|
||||||
|
func appendUnique(base []string, extra ...string) []string {
|
||||||
|
seen := make(map[string]bool, len(base))
|
||||||
|
for _, s := range base {
|
||||||
|
seen[s] = true
|
||||||
|
}
|
||||||
|
for _, s := range extra {
|
||||||
|
if s != "" && !seen[s] {
|
||||||
|
seen[s] = true
|
||||||
|
base = append(base, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return base
|
||||||
}
|
}
|
||||||
|
|
||||||
func envOr(k, def string) string {
|
func envOr(k, def string) string {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@@ -40,6 +41,64 @@ func TestSplitPrincipals(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDhcpDomain(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
rc := dir + "/resolv.conf"
|
||||||
|
old := resolvConf
|
||||||
|
resolvConf = rc
|
||||||
|
defer func() { resolvConf = old }()
|
||||||
|
|
||||||
|
if err := os.WriteFile(rc, []byte("domain corp.example\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if d := dhcpDomain(); d != "corp.example" {
|
||||||
|
t.Errorf("domain line: got %q", d)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(rc, []byte("nameserver 1.1.1.1\nsearch a.lab b.lab\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if d := dhcpDomain(); d != "a.lab" {
|
||||||
|
t.Errorf("search line: got %q", d)
|
||||||
|
}
|
||||||
|
os.Remove(rc)
|
||||||
|
if d := dhcpDomain(); d != "" {
|
||||||
|
t.Errorf("missing file: got %q", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultPrincipalsFQDN(t *testing.T) {
|
||||||
|
h, _ := os.Hostname()
|
||||||
|
if h == "" || strings.Contains(h, ".") {
|
||||||
|
t.Skip("host name unsuitable for this test")
|
||||||
|
}
|
||||||
|
dir := t.TempDir()
|
||||||
|
rc := dir + "/resolv.conf"
|
||||||
|
if err := os.WriteFile(rc, []byte("nameserver 10.0.0.1\nsearch fleet.lab example.org\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
old := resolvConf
|
||||||
|
resolvConf = rc
|
||||||
|
defer func() { resolvConf = old }()
|
||||||
|
|
||||||
|
got := defaultPrincipals()
|
||||||
|
if len(got) != 2 || got[0] != h || got[1] != h+".fleet.lab" {
|
||||||
|
t.Errorf("defaultPrincipals = %v, want [%s %s.fleet.lab]", got, h, h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppendUnique(t *testing.T) {
|
||||||
|
got := appendUnique([]string{"a", "b"}, "b", "c", "", "a", "d")
|
||||||
|
want := []string{"a", "b", "c", "d"}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("got %v want %v", got, want)
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Fatalf("got %v want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseHandle(t *testing.T) {
|
func TestParseHandle(t *testing.T) {
|
||||||
h, err := parseHandle("0x81000100")
|
h, err := parseHandle("0x81000100")
|
||||||
if err != nil || uint32(h) != 0x81000100 {
|
if err != nil || uint32(h) != 0x81000100 {
|
||||||
|
|||||||
+7
-32
@@ -109,38 +109,13 @@ SUBNQN=nqn.2026-06.org.example.fleet:rootfs
|
|||||||
TARGET_PORT=4420
|
TARGET_PORT=4420
|
||||||
SPDK_CPUMASK=0x1 # one polling core is plenty for a read-only boot fs
|
SPDK_CPUMASK=0x1 # one polling core is plenty for a read-only boot fs
|
||||||
|
|
||||||
# --- TPM enrolment broker (TEMPORARY in this stack — see docs/TODO.md #11) ---
|
# --- TPM enrolment broker — configured in the CA-co-located stack -----------
|
||||||
# The broker is the one unauthenticated pre-auth endpoint (nginx proxies /enrol/
|
# The broker no longer runs in this stack (a mock-signer copy here collided with
|
||||||
# to it). Defaults run the zero-dependency LAB path: a `mock` signer (ephemeral
|
# the real one on 9090/9443 and issued untrusted certs — removed 2026-07-11). It
|
||||||
# CA; certs NOT trusted by RADIUS/NVMe) + plaintext loopback admin — enough to
|
# lives in serving/dot1x-bench/ alongside step-ca (real signing needs the CA), and
|
||||||
# exercise the request/approve/poll flow while the host side is built.
|
# the `http` nginx here still proxies /enrol/ -> 127.0.0.1:9090 to reach it on the
|
||||||
#
|
# same host. Configure the broker (signer, provisioner, admin mTLS) in
|
||||||
# For REAL enrolment: set ENROL_SIGNER=step, point ENROL_STEP_CA_URL at a
|
# serving/dot1x-bench/.env, not here.
|
||||||
# reachable step-ca (e.g. the one in serving/dot1x-bench/), drop its root +
|
|
||||||
# provisioner password into ./enrol-pki/, generate the admin mTLS PKI
|
|
||||||
# (./enrol-broker/admin-pki.sh ./enrol-pki/broker <admin>), and set
|
|
||||||
# ENROL_ADMIN_INSECURE=false. Then it belongs in its own CA-co-located stack.
|
|
||||||
ENROL_SIGNER=mock # mock (lab, no CA) | step (real, needs a CA)
|
|
||||||
ENROL_ADMIN_INSECURE=true # true = plaintext loopback admin (LAB ONLY); false = mTLS
|
|
||||||
ENROL_PENDING_TTL=10m # time-box on unapproved requests
|
|
||||||
ENROL_WORDS=8 # fingerprint word count
|
|
||||||
ENROL_HOSTNQN_BASE=nqn.2026-06.org.example.fleet:host-
|
|
||||||
# step signer (only when ENROL_SIGNER=step) — paths are INSIDE the container
|
|
||||||
# (./enrol-pki is mounted at /pki):
|
|
||||||
ENROL_STEP_CA_URL= # e.g. https://ca.fleet.lab:8443
|
|
||||||
ENROL_STEP_CA_ROOT=/pki/root_ca.crt
|
|
||||||
ENROL_STEP_PROVISIONER=enrol
|
|
||||||
ENROL_STEP_PASSWORD_FILE=/pki/password
|
|
||||||
# Issued-cert lifetime. NOTE: the step-ca provisioner must ALLOW this — a stock
|
|
||||||
# step-ca caps certs at 24h, so 720h is rejected ("requested duration ... more
|
|
||||||
# than the authorized maximum certificate duration of 24h"). The bench
|
|
||||||
# step-ca/bootstrap.sh now raises authority.claims.maxTLSCertDuration; a
|
|
||||||
# different CA needs the same. Verified end-to-end against a real step-ca.
|
|
||||||
ENROL_CERT_NOT_AFTER=720h
|
|
||||||
# admin mTLS (only when ENROL_ADMIN_INSECURE=false):
|
|
||||||
ENROL_ADMIN_CERT=/pki/broker/server.crt
|
|
||||||
ENROL_ADMIN_KEY=/pki/broker/server.key
|
|
||||||
ENROL_ADMIN_CLIENT_CA=/pki/broker/admin-ca.crt
|
|
||||||
|
|
||||||
# --- SPDK version loader / reaper (declarative rolling updates) -------------
|
# --- SPDK version loader / reaper (declarative rolling updates) -------------
|
||||||
# The spdk-loader service stages + adds ${IMAGE_VERSION} to the long-lived target
|
# The spdk-loader service stages + adds ${IMAGE_VERSION} to the long-lived target
|
||||||
|
|||||||
@@ -201,50 +201,15 @@ services:
|
|||||||
# For HTTPS boot (production), terminate TLS here with the boot-infra CA's
|
# For HTTPS boot (production), terminate TLS here with the boot-infra CA's
|
||||||
# cert and switch the dhcp-boot URL above to https://. Left HTTP for the lab.
|
# cert and switch the dhcp-boot URL above to https://. Left HTTP for the lab.
|
||||||
|
|
||||||
# --- TPM enrolment broker (TEMPORARY home — see docs/TODO.md #11) -----------
|
# --- TPM enrolment broker — NOT defined here (lives in the CA stack) --------
|
||||||
# The `http` nginx already reverse-proxies /enrol/ -> 127.0.0.1:9090 (this
|
# The broker's home is co-located with step-ca (the fleet CA), i.e.
|
||||||
# broker's public listener), so an empty-NV host can POST its CSR and poll for
|
# serving/dot1x-bench/, since real signing needs that CA. A `mock`-signer
|
||||||
# the signed cert via the normal serving host. For now the broker runs HERE so
|
# convenience copy used to run here too, but two host-networked brokers collide
|
||||||
# a single `compose up` in serving/ brings the enrolment endpoint up.
|
# on 9090/9443 and the mock copy issued certs RADIUS/NVMe don't trust — so it was
|
||||||
#
|
# removed (bench 2026-07-11; see docs/ssh-host-certificate-design.md). The `http`
|
||||||
# ⚠ PROPER HOME is its own stack co-located with step-ca (the fleet CA), i.e.
|
# nginx still reverse-proxies /enrol/ -> 127.0.0.1:9090; bring up
|
||||||
# serving/dot1x-bench/, since REAL signing needs that CA. SPLIT THIS BACK OUT
|
# serving/dot1x-bench on the same host (its broker binds that loopback port) to
|
||||||
# once the CA lives beside it. Do NOT run this AND dot1x-bench's enrol-broker on
|
# serve enrolment. Do NOT re-add a second broker here.
|
||||||
# the same host — they collide on 9090/9443.
|
|
||||||
#
|
|
||||||
# Defaults below are the zero-dependency LAB path: `mock` signer (ephemeral
|
|
||||||
# in-process CA — certs are NOT trusted by RADIUS/NVMe) + plaintext loopback
|
|
||||||
# admin. For REAL enrolment, in .env set ENROL_SIGNER=step + point
|
|
||||||
# ENROL_STEP_CA_* at a reachable step-ca, and set ENROL_ADMIN_INSECURE=false
|
|
||||||
# after generating the admin mTLS PKI (a DEDICATED admin CA, not the fleet root):
|
|
||||||
# ./enrol-broker/admin-pki.sh ./enrol-pki/broker <admin-name>
|
|
||||||
# then import enrol-pki/broker/clients/<admin>.p12 and open https://127.0.0.1:9443/
|
|
||||||
enrol-broker:
|
|
||||||
build: ./enrol-broker
|
|
||||||
network_mode: host # public on 127.0.0.1:9090 (nginx proxies /enrol/)
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
BROKER_PUBLIC_ADDR: "127.0.0.1:9090" # nginx /enrol/ proxies here
|
|
||||||
BROKER_ADMIN_ADDR: "127.0.0.1:9443" # mgmt/loopback ONLY — never the boot LAN
|
|
||||||
BROKER_SIGNER: ${ENROL_SIGNER:-mock}
|
|
||||||
BROKER_PENDING_TTL: ${ENROL_PENDING_TTL:-10m}
|
|
||||||
BROKER_WORDS: ${ENROL_WORDS:-8}
|
|
||||||
HOSTNQN_BASE: ${ENROL_HOSTNQN_BASE:-nqn.2026-06.org.example.fleet:host-}
|
|
||||||
# --- step signer (only consulted when ENROL_SIGNER=step) ---
|
|
||||||
STEP_CA_URL: ${ENROL_STEP_CA_URL:-}
|
|
||||||
STEP_CA_ROOT: ${ENROL_STEP_CA_ROOT:-/pki/root_ca.crt}
|
|
||||||
STEP_PROVISIONER: ${ENROL_STEP_PROVISIONER:-enrol}
|
|
||||||
STEP_PROVISIONER_PASSWORD_FILE: ${ENROL_STEP_PASSWORD_FILE:-/pki/password}
|
|
||||||
STEP_CERT_NOT_AFTER: ${ENROL_CERT_NOT_AFTER:-720h}
|
|
||||||
# --- admin mTLS (set ENROL_ADMIN_INSECURE=false for real mTLS) ---
|
|
||||||
BROKER_ADMIN_INSECURE: ${ENROL_ADMIN_INSECURE:-true}
|
|
||||||
BROKER_ADMIN_CERT: ${ENROL_ADMIN_CERT:-/pki/broker/server.crt}
|
|
||||||
BROKER_ADMIN_KEY: ${ENROL_ADMIN_KEY:-/pki/broker/server.key}
|
|
||||||
BROKER_ADMIN_CLIENT_CA: ${ENROL_ADMIN_CLIENT_CA:-/pki/broker/admin-ca.crt}
|
|
||||||
volumes:
|
|
||||||
# CA root + provisioner password (step signer) and admin mTLS certs live
|
|
||||||
# here; unused/empty for the default mock+insecure path. Gitignored.
|
|
||||||
- ./enrol-pki:/pki:ro
|
|
||||||
|
|
||||||
# --- Kiosk SPA (spaceship terminal shown by the diskless kiosk) ------------
|
# --- Kiosk SPA (spaceship terminal shown by the diskless kiosk) ------------
|
||||||
# Svelte + Tailwind app built into a static bundle served by its own nginx.
|
# Svelte + Tailwind app built into a static bundle served by its own nginx.
|
||||||
|
|||||||
@@ -70,7 +70,12 @@ services:
|
|||||||
BROKER_PUBLIC_ADDR: "127.0.0.1:9090"
|
BROKER_PUBLIC_ADDR: "127.0.0.1:9090"
|
||||||
BROKER_ADMIN_ADDR: "127.0.0.1:9443"
|
BROKER_ADMIN_ADDR: "127.0.0.1:9443"
|
||||||
BROKER_SIGNER: "step"
|
BROKER_SIGNER: "step"
|
||||||
STEP_CA_URL: "https://${CA_DNS:-ca.fleet.lab}:${CA_PORT:-8443}"
|
# Co-located with step-ca (both network_mode: host), so reach it over
|
||||||
|
# loopback — NOT the fleet DNS name. ca.fleet.lab is a dnsmasq name for
|
||||||
|
# boot-LAN clients; the broker's host resolver doesn't know it, so signing
|
||||||
|
# died with "lookup ca.fleet.lab: no such host" (bench 2026-07-11). localhost
|
||||||
|
# is always in the CA cert SAN (DOCKER_STEPCA_INIT_DNS_NAMES) so TLS verifies.
|
||||||
|
STEP_CA_URL: "https://localhost:${CA_PORT:-8443}"
|
||||||
STEP_CA_ROOT: "/pki/root_ca.crt"
|
STEP_CA_ROOT: "/pki/root_ca.crt"
|
||||||
STEP_PROVISIONER: "enrol"
|
STEP_PROVISIONER: "enrol"
|
||||||
STEP_PROVISIONER_PASSWORD_FILE: "/pki/stepca/secrets/password"
|
STEP_PROVISIONER_PASSWORD_FILE: "/pki/stepca/secrets/password"
|
||||||
|
|||||||
@@ -82,10 +82,33 @@ fi
|
|||||||
# The broker authenticates to step-ca with this JWK provisioner (signer.go). It
|
# The broker authenticates to step-ca with this JWK provisioner (signer.go). It
|
||||||
# must be allowed to issue SSH HOST certs. BENCH-VERIFY the exact subcommand/flag
|
# must be allowed to issue SSH HOST certs. BENCH-VERIFY the exact subcommand/flag
|
||||||
# on your step version (some use `provisioner update <name> --ssh`).
|
# on your step version (some use `provisioner update <name> --ssh`).
|
||||||
echo "==> enabling SSH on provisioner '${PROVISIONER}' (BENCH-VERIFY the flag)"
|
echo "==> enabling SSH host-cert issuance on provisioner '${PROVISIONER}'"
|
||||||
incca step ca provisioner update "${PROVISIONER}" --ssh 2>/dev/null \
|
# FAIL LOUDLY: a swallowed failure here leaves enableSSHCA=false, and every
|
||||||
&& restart_ca \
|
# `step ssh certificate` at runtime dies with a 502 whose cause the operator
|
||||||
|| echo " NOTE: could not auto-enable SSH on '${PROVISIONER}' — enable it by hand (step ca provisioner update ${PROVISIONER} --ssh) and restart step-ca."
|
# can't see (bench 2026-07-11). If your `step` uses a different flag, fix it and
|
||||||
|
# re-run rather than shipping a half-configured CA.
|
||||||
|
if ! incca step ca provisioner update "${PROVISIONER}" --ssh; then
|
||||||
|
echo "ERROR: 'step ca provisioner update ${PROVISIONER} --ssh' failed. Your step" >&2
|
||||||
|
echo " version may use a different flag — check: step ca provisioner update --help" >&2
|
||||||
|
echo " Aborting so SSH host-cert signing isn't silently left disabled." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
restart_ca
|
||||||
|
# Verify it actually took — the CA only issues SSH host certs if enableSSHCA=true.
|
||||||
|
if ! python3 - "$CA_JSON" "$PROVISIONER" <<'PY'
|
||||||
|
import json,sys
|
||||||
|
c=json.load(open(sys.argv[1])); name=sys.argv[2]
|
||||||
|
for p in c["authority"]["provisioners"]:
|
||||||
|
if p.get("name")==name and p.get("claims",{}).get("enableSSHCA") is True:
|
||||||
|
sys.exit(0)
|
||||||
|
sys.exit(1)
|
||||||
|
PY
|
||||||
|
then
|
||||||
|
echo "ERROR: provisioner '${PROVISIONER}' still not SSH-enabled (enableSSHCA != true)" >&2
|
||||||
|
echo " after the update + restart. SSH host-cert signing would fail at runtime; aborting." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo " SSH host-cert issuance enabled on '${PROVISIONER}' (enableSSHCA=true)."
|
||||||
|
|
||||||
# --- 3. broker host-plane server cert + fleet client-CA bundle --------------
|
# --- 3. broker host-plane server cert + fleet client-CA bundle --------------
|
||||||
# The host plane is mTLS: its SERVER cert must be FLEET-signed (the booted host
|
# The host plane is mTLS: its SERVER cert must be FLEET-signed (the booted host
|
||||||
@@ -104,8 +127,19 @@ cat "$PKI/root_ca.crt" > "$PKI/broker/fleet-client-ca.crt"
|
|||||||
[ -f "$PKI/intermediate_ca.crt" ] && cat "$PKI/intermediate_ca.crt" >> "$PKI/broker/fleet-client-ca.crt"
|
[ -f "$PKI/intermediate_ca.crt" ] && cat "$PKI/intermediate_ca.crt" >> "$PKI/broker/fleet-client-ca.crt"
|
||||||
|
|
||||||
# --- 4. export the SSH host CA public key (admin trust anchor) ---------------
|
# --- 4. export the SSH host CA public key (admin trust anchor) ---------------
|
||||||
echo "==> exporting SSH host CA public key -> $PKI/ssh_host_ca.pub"
|
# `step crypto keypair` writes the public key in PEM/PKCS8, but a known_hosts
|
||||||
incca cp /home/step/secrets/ssh_host_ca_key.pub /export/ssh_host_ca.pub
|
# `@cert-authority` line needs the single-line OpenSSH wire format
|
||||||
|
# (`ecdsa-sha2-nistp256 AAAA…`). Emitting the PEM verbatim yields an unparseable
|
||||||
|
# anchor → ssh silently falls back to TOFU (bench 2026-07-11). Convert it.
|
||||||
|
echo "==> exporting SSH host CA public key (OpenSSH format) -> $PKI/ssh_host_ca.pub"
|
||||||
|
incca cp /home/step/secrets/ssh_host_ca_key.pub /export/ssh_host_ca_key.pem
|
||||||
|
command -v ssh-keygen >/dev/null 2>&1 || {
|
||||||
|
echo "ERROR: ssh-keygen not found — cannot convert the CA key to OpenSSH format." >&2
|
||||||
|
echo " Install openssh-clients; the raw PKCS8 key is at $PKI/ssh_host_ca_key.pem." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
ssh-keygen -i -m PKCS8 -f "$PKI/ssh_host_ca_key.pem" > "$PKI/ssh_host_ca.pub"
|
||||||
|
rm -f "$PKI/ssh_host_ca_key.pem"
|
||||||
echo
|
echo
|
||||||
echo "Admin rollout — add ONE line to each admin's ~/.ssh/known_hosts (or a system"
|
echo "Admin rollout — add ONE line to each admin's ~/.ssh/known_hosts (or a system"
|
||||||
echo "known_hosts), so every fleet host is trusted via the CA instead of TOFU:"
|
echo "known_hosts), so every fleet host is trusted via the CA instead of TOFU:"
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ PUBLIC (plain HTTP, behind the serving nginx /enrol/) UNAUTHENTICATED
|
|||||||
GET /healthz
|
GET /healthz
|
||||||
|
|
||||||
ADMIN (mTLS, management network / loopback ONLY) admin client cert
|
ADMIN (mTLS, management network / loopback ONLY) admin client cert
|
||||||
GET / the pending-queue web UI (fingerprint + approve/deny)
|
GET / plaintext pointer (API only; human UI is the fleet-manager SPA)
|
||||||
GET /pending JSON queue
|
GET /pending JSON queue
|
||||||
POST /approve/{id} OOB-approve -> step ca sign -> store cert
|
POST /approve/{id} OOB-approve -> step ca sign -> store cert
|
||||||
POST /deny/{id} reject
|
POST /deny/{id} reject
|
||||||
@@ -74,9 +74,11 @@ docker compose up -d step-ca && ./step-ca/bootstrap.sh # CA + root_ca.crt + en
|
|||||||
docker compose up -d enrol-broker
|
docker compose up -d enrol-broker
|
||||||
```
|
```
|
||||||
|
|
||||||
Then import `pki/broker/clients/alice.p12` (empty password) into your browser and
|
Approve/deny is driven by the **fleet-manager SPA** (passkey-gated), which calls
|
||||||
open `https://127.0.0.1:9443/`. Hosts reach the public side via the serving
|
this plane's JSON API over mTLS; the broker no longer serves its own HTML console.
|
||||||
stack's nginx: `http://boot.fleet.lab/enrol/`.
|
For a scripted/manual approve, use the admin client cert directly against the API,
|
||||||
|
e.g. `curl --cert/--key … https://127.0.0.1:9443/pending`. Hosts reach the public
|
||||||
|
side via the serving stack's nginx: `http://boot.fleet.lab/enrol/`.
|
||||||
|
|
||||||
### Admin mTLS PKI — important
|
### Admin mTLS PKI — important
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// admin.go — the mTLS admin plane: the pending-queue web UI + approve/deny.
|
// admin.go — the mTLS admin plane: the pending-queue + approve/deny JSON API.
|
||||||
//
|
//
|
||||||
// This listener is the AUTHENTICATED half of the broker and must live on the
|
// This listener is the AUTHENTICATED half of the broker and must live on the
|
||||||
// management network / loopback only — NEVER the boot LAN (docs invariant: the
|
// management network / loopback only — NEVER the boot LAN (docs invariant: the
|
||||||
@@ -11,7 +11,6 @@ package main
|
|||||||
import (
|
import (
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"html/template"
|
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@@ -21,7 +20,7 @@ import (
|
|||||||
|
|
||||||
func (b *broker) serveAdmin() {
|
func (b *broker) serveAdmin() {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("GET /", b.handleAdminIndex)
|
mux.HandleFunc("GET /", b.handleAdminRoot)
|
||||||
mux.HandleFunc("GET /pending", b.handleAdminPending)
|
mux.HandleFunc("GET /pending", b.handleAdminPending)
|
||||||
mux.HandleFunc("POST /approve/{id}", b.handleApprove)
|
mux.HandleFunc("POST /approve/{id}", b.handleApprove)
|
||||||
mux.HandleFunc("POST /deny/{id}", b.handleDeny)
|
mux.HandleFunc("POST /deny/{id}", b.handleDeny)
|
||||||
@@ -121,94 +120,32 @@ func (b *broker) handleDeny(w http.ResponseWriter, r *http.Request) {
|
|||||||
respondDecision(w, r, e)
|
respondDecision(w, r, e)
|
||||||
}
|
}
|
||||||
|
|
||||||
// respondDecision replies to an approve/deny. A browser form gets redirected back
|
// respondDecision replies to an approve/deny. A caller that asks for JSON
|
||||||
// to the queue; a caller that asks for JSON (Accept: application/json — e.g. the
|
// (Accept: application/json — the fleet-manager driving the broker as its
|
||||||
// fleet-manager driving the broker as its authorise plane) gets the decided
|
// authorise plane) gets the decided Enrolment (cert_serial, hostnqn, context,
|
||||||
// Enrolment (cert_serial, hostnqn, context, fingerprint — NOT the cert PEM, which
|
// fingerprint — NOT the cert PEM, which is unexported and only reaches the
|
||||||
// is unexported and only reaches the requesting host); any other API caller keeps
|
// requesting host); any other API caller keeps the original 204 (so
|
||||||
// the original 204 (so verify-serving.sh and existing scripts are unaffected).
|
// verify-serving.sh and existing scripts are unaffected).
|
||||||
func respondDecision(w http.ResponseWriter, r *http.Request, e *Enrolment) {
|
func respondDecision(w http.ResponseWriter, r *http.Request, e *Enrolment) {
|
||||||
switch {
|
if wantsJSON(r) {
|
||||||
case isBrowser(r):
|
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
||||||
case wantsJSON(r):
|
|
||||||
writeJSON(w, http.StatusOK, e)
|
writeJSON(w, http.StatusOK, e)
|
||||||
default:
|
return
|
||||||
w.WriteHeader(http.StatusNoContent)
|
|
||||||
}
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
func wantsJSON(r *http.Request) bool {
|
func wantsJSON(r *http.Request) bool {
|
||||||
return strings.Contains(r.Header.Get("Accept"), "application/json")
|
return strings.Contains(r.Header.Get("Accept"), "application/json")
|
||||||
}
|
}
|
||||||
|
|
||||||
func isBrowser(r *http.Request) bool {
|
// handleAdminRoot: the human approval UI has moved to the fleet-manager SPA
|
||||||
a := r.Header.Get("Accept")
|
// (WebAuthn-passkey gated), which drives this plane's JSON API over mTLS. This
|
||||||
return len(a) >= 9 && a[:9] == "text/html" || // explicit
|
// listener is now API-only — the old embedded HTML console was retired to avoid a
|
||||||
r.Header.Get("Sec-Fetch-Mode") == "navigate"
|
// second, weaker (client-cert-only) human approve path parallel to the passkey
|
||||||
|
// UI. Kept as a plaintext pointer so a bookmark to the old console isn't a 404.
|
||||||
|
func (b *broker) handleAdminRoot(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
|
_, _ = w.Write([]byte("enrol-broker admin plane — API only.\n" +
|
||||||
|
" GET /pending\n POST /approve/{id}\n POST /deny/{id}\n" +
|
||||||
|
"The human approval UI is the fleet-manager SPA (passkey-gated).\n"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *broker) handleAdminIndex(w http.ResponseWriter, r *http.Request) {
|
|
||||||
data := struct {
|
|
||||||
Admin string
|
|
||||||
Pending []*Enrolment
|
|
||||||
Now time.Time
|
|
||||||
}{
|
|
||||||
Admin: adminCN(r),
|
|
||||||
Pending: b.store.Pending(),
|
|
||||||
Now: time.Now(),
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
if err := adminTmpl.Execute(w, data); err != nil {
|
|
||||||
log.Printf("admin template: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var adminTmpl = template.Must(template.New("admin").Funcs(template.FuncMap{
|
|
||||||
"age": func(t time.Time) string { return time.Since(t).Round(time.Second).String() },
|
|
||||||
}).Parse(adminHTML))
|
|
||||||
|
|
||||||
const adminHTML = `<!doctype html>
|
|
||||||
<html lang="en"><head><meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>fleet enrolment — pending</title>
|
|
||||||
<style>
|
|
||||||
:root{color-scheme:dark}
|
|
||||||
body{background:#0b0f14;color:#cfe3ee;font:14px/1.5 ui-monospace,Menlo,Consolas,monospace;margin:0;padding:1.5rem}
|
|
||||||
h1{font-size:1.1rem;color:#7fd1ff;margin:0 0 .25rem}
|
|
||||||
.sub{color:#6b8294;margin:0 0 1.25rem}
|
|
||||||
.empty{color:#6b8294;padding:2rem 0}
|
|
||||||
.card{background:#111923;border:1px solid #1f2d3a;border-radius:8px;padding:1rem 1.1rem;margin:0 0 1rem}
|
|
||||||
.fp{font-size:1.25rem;color:#aef9b0;letter-spacing:.5px;word-break:break-word}
|
|
||||||
.meta{display:grid;grid-template-columns:max-content 1fr;gap:.1rem .8rem;margin:.6rem 0;color:#9fb6c4}
|
|
||||||
.meta b{color:#6b8294;font-weight:400}
|
|
||||||
.actions{margin-top:.7rem}
|
|
||||||
button{font:inherit;border:0;border-radius:6px;padding:.45rem .9rem;cursor:pointer}
|
|
||||||
.approve{background:#15602f;color:#d6ffe0}
|
|
||||||
.deny{background:#5e1b1b;color:#ffd9d9;margin-left:.5rem}
|
|
||||||
form{display:inline}
|
|
||||||
code{color:#cfe3ee}
|
|
||||||
</style></head><body>
|
|
||||||
<h1>fleet enrolment — pending approvals</h1>
|
|
||||||
<p class="sub">admin: <code>{{.Admin}}</code> · {{len .Pending}} pending · cross-check each fingerprint against the device's splash before approving</p>
|
|
||||||
{{if not .Pending}}<div class="empty">no pending enrolments.</div>{{end}}
|
|
||||||
{{range .Pending}}
|
|
||||||
<div class="card">
|
|
||||||
<div class="fp">{{.Fingerprint}}</div>
|
|
||||||
<div class="meta">
|
|
||||||
<b>enrol-id</b><code>{{.ID}}</code>
|
|
||||||
<b>CN</b><code>{{.CN}}</code>
|
|
||||||
{{if .Context.MAC}}<b>MAC</b><code>{{.Context.MAC}}</code>{{end}}
|
|
||||||
{{if .Context.DMISerial}}<b>DMI serial</b><code>{{.Context.DMISerial}}</code>{{end}}
|
|
||||||
{{if .Context.DMIUUID}}<b>DMI uuid</b><code>{{.Context.DMIUUID}}</code>{{end}}
|
|
||||||
{{if .Context.EKHash}}<b>TPM EK</b><code>{{.Context.EKHash}}</code>{{end}}
|
|
||||||
<b>pubkey</b><code>{{.PubKeyHash}}</code>
|
|
||||||
<b>age</b><code>{{age .CreatedAt}}</code>
|
|
||||||
</div>
|
|
||||||
<div class="actions">
|
|
||||||
<form method="post" action="/approve/{{.ID}}"><button class="approve" type="submit">approve</button></form>
|
|
||||||
<form method="post" action="/deny/{{.ID}}"><button class="deny" type="submit">deny</button></form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
</body></html>`
|
|
||||||
|
|||||||
Reference in New Issue
Block a user