diff --git a/agent/cmd/ddi-agent-dhcp/main.go b/agent/cmd/ddi-agent-dhcp/main.go index a3b3129..3230491 100644 --- a/agent/cmd/ddi-agent-dhcp/main.go +++ b/agent/cmd/ddi-agent-dhcp/main.go @@ -11,5 +11,5 @@ import ( ) func main() { - os.Exit(core.RunCLI(brand.AgentDHCPBinary, core.RoleDHCP, os.Args[1:], os.Stdout, os.Stderr)) + os.Exit(core.RunCLI(brand.AgentDHCPBinary, core.KindDHCP, os.Args[1:], os.Stdout, os.Stderr)) } diff --git a/agent/cmd/ddi-agent-dns/main.go b/agent/cmd/ddi-agent-dns/main.go index dcc78f0..a32150c 100644 --- a/agent/cmd/ddi-agent-dns/main.go +++ b/agent/cmd/ddi-agent-dns/main.go @@ -11,5 +11,5 @@ import ( ) func main() { - os.Exit(core.RunCLI(brand.AgentDNSBinary, core.RoleDNS, os.Args[1:], os.Stdout, os.Stderr)) + os.Exit(core.RunCLI(brand.AgentDNSBinary, core.KindDNS, os.Args[1:], os.Stdout, os.Stderr)) } diff --git a/agent/core/cli.go b/agent/core/cli.go index bf485b8..19bf7c6 100644 --- a/agent/core/cli.go +++ b/agent/core/cli.go @@ -13,17 +13,8 @@ import ( "git.beugsiud.de/beug.k/DDI/internal/buildinfo" ) -// Role identifies an agent role. -type Role string - -// Supported roles. -const ( - RoleDNS Role = "dns" - RoleDHCP Role = "dhcp" -) - // RunCLI dispatches the agent command line and returns the process exit code. -func RunCLI(binary string, role Role, args []string, stdout, stderr io.Writer) int { +func RunCLI(binary string, role Kind, args []string, stdout, stderr io.Writer) int { if len(args) == 0 { usage(stderr, binary, role) return 2 @@ -44,7 +35,7 @@ func RunCLI(binary string, role Role, args []string, stdout, stderr io.Writer) i } } -func usage(w io.Writer, binary string, role Role) { +func usage(w io.Writer, binary string, role Kind) { _, _ = fmt.Fprintf(w, `Usage: %s Agent role: %s diff --git a/agent/core/cli_test.go b/agent/core/cli_test.go index a410ece..ff9a76c 100644 --- a/agent/core/cli_test.go +++ b/agent/core/cli_test.go @@ -25,7 +25,7 @@ func TestRunCLI(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { var out, errOut bytes.Buffer - code := RunCLI("ddi-agent-test", RoleDNS, tt.args, &out, &errOut) + code := RunCLI("ddi-agent-test", KindDNS, tt.args, &out, &errOut) if code != tt.wantCode { t.Errorf("code = %d, want %d", code, tt.wantCode) } diff --git a/agent/core/role.go b/agent/core/role.go new file mode 100644 index 0000000..1ad2ded --- /dev/null +++ b/agent/core/role.go @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: Apache-2.0 + +package core + +import ( + "context" + "net/netip" + "time" + + "git.beugsiud.de/beug.k/DDI/internal/bundle" +) + +// Kind identifies an agent role. +type Kind string + +// Supported roles. The values equal the role in bundle manifests. +const ( + KindDNS Kind = "dns" + KindDHCP Kind = "dhcp" +) + +// Revision is a verified configuration revision stored by the core under +// /revisions//. Roles must treat it as read-only. +type Revision struct { + Number uint64 + // Dir holds the artifacts at their manifest paths (bundle.CheckPath + // guarantees they stay inside Dir). + Dir string + Files []bundle.File +} + +// Role applies revisions to one managed service (BIND or Kea). The core +// drives it as follows (docs/ARCHITECTURE.md, section 4.2): +// +// 1. Validate(rev). An error means the revision is rejected; nothing on the +// host may have changed. +// 2. Activate(rev, prev). Makes rev the live configuration and reloads the +// service. An error triggers a rollback. +// 3. Health(&rev), retried for a while. A non-OK state triggers a rollback. +// 4. Rollback: Activate(lastGood, &rev) followed by Health(&lastGood). +// +// Files marked CreateOnly are written only if they do not exist and are never +// overwritten or removed. Activate removes files that prev had and rev does +// not (except CreateOnly ones). +type Role interface { + // Kind returns the role. + Kind() Kind + // ServiceVersion returns the managed service's version, e.g. + // "BIND 9.20.3" or "Kea 3.2.1". + ServiceVersion(ctx context.Context) (string, error) + // Validate checks rev without changing the live configuration. + Validate(ctx context.Context, rev Revision) error + // Activate installs rev and reloads the service. prev is the revision + // that was active before (nil if none). + Activate(ctx context.Context, rev Revision, prev *Revision) error + // Health checks the running service. expected is the revision that + // should be active (nil if none); the DNS role compares SOA serials. + Health(ctx context.Context, expected *Revision) Health +} + +// HealthState summarizes a health check. +type HealthState int + +// Health states (mirroring the protocol's HealthState). +const ( + HealthUnknown HealthState = iota + HealthOK + HealthDegraded + HealthFailed +) + +// Health is the result of Role.Health. +type Health struct { + State HealthState + Message string + Checks []HealthCheck +} + +// HealthCheck is one named check within a Health result. +type HealthCheck struct { + Name string + State HealthState + Message string +} + +// LeaseState mirrors the protocol's LeaseState. +type LeaseState int + +// Lease states. +const ( + LeaseUnknown LeaseState = iota + LeaseActive + LeaseExpired + LeaseReleased + LeaseDeclined +) + +// Lease is one DHCP lease as seen by the managed service. +type Lease struct { + IP netip.Addr + MAC string // lowercase, colon separated + Hostname string + State LeaseState + Expires time.Time + Updated time.Time // last client transaction + SubnetID uint32 + ClientID string +} + +// LeaseSource is implemented by roles that can list leases (DHCP). The core +// polls it, computes changes and reports them to the server. +type LeaseSource interface { + Leases(ctx context.Context) ([]Lease, error) +} + +// RoleFactory creates a role from its section of the agent configuration +// file. decode unmarshals that section (YAML) into the role's own struct; an +// absent section decodes nothing, so roles must start from defaults. +type RoleFactory func(decode func(v any) error) (Role, error) diff --git a/docs/adr/0021-agent-strecke-m3.md b/docs/adr/0021-agent-strecke-m3.md new file mode 100644 index 0000000..97709ce --- /dev/null +++ b/docs/adr/0021-agent-strecke-m3.md @@ -0,0 +1,55 @@ +# 0021 – Agent-Strecke: Revisionen, PKI, Ablauf im Agent (M3) + +- **Status:** Akzeptiert +- **Datum:** 2026-10-13 + +## Kontext + +ADR 0007–0011 legen Protokoll, CA, Signatur, Binaries und Agent-Zustand fest. Für M3 (erste durchgängige Strecke) fehlen die konkreten Regeln, auf die sich Server, Agent-Kern und Rollen verlassen. Gemeinsame Typen liegen in `internal/bundle` (Manifest, Signatur), `internal/agentpki` (Agent-ID im Zertifikat, CA-Fingerprint, TLS) und `agent/core/role.go` (Rollen-Schnittstelle); die REST-API steht in `server/api/openapi.yaml`. + +## Entscheidung + +### Revisionen + +- Eine **globale, fortlaufende Revisionsnummer** (1 … 2³²−1, weil sie zugleich SOA-Seriennummer ist). Eine Revision enthält die DNS- und die DHCP-Artefakte, Warnungen, Kommentar, Ersteller und Zeitpunkt und ist **unveränderlich**. +- Erzeugt wird sie ausdrücklich mit `POST /api/v1/revisions` (Recht `deploy`, im Lesemodus gesperrt). Der Server erzeugt die Konfiguration zuerst mit der Nummer der letzten Revision; ist sie byte-gleich, entsteht keine neue Revision (Antwort 200). Sonst wird mit der nächsten Nummer erzeugt und gespeichert (201). Kann ein Teil nicht erzeugt werden (z. B. fehlende DNS-Einstellungen), wird nichts gespeichert (422). +- Soll-Revision jedes Agents ist in M3 die neueste Revision. Betriebsmodi pro Agent, Push aus dem UI und Entwürfe folgen in M4; das Feld `mode` existiert bereits (Standard `auto_pull`). +- Bundles werden beim Abruf (`FetchBundle`) für den Agent signiert: Agent-ID und Rolle aus dem Zertifikat, Artefakte der Rolle, `created_at` = Zeitpunkt der Revision. Gleiche Eingabe ergibt gleiche Bytes. + +### PKI und Agent-API + +- Beim ersten Start erzeugt der Server die **Agent-CA** (ECDSA P-256, 10 Jahre) und den **Bundle-Signaturschlüssel** (Ed25519). Beide privaten Schlüssel liegen mit dem Master-Key verschlüsselt in der Datenbank (ADR 0015). +- Das **Serverzertifikat** des Agent-Ports wird bei jedem Start im Speicher aus der CA ausgestellt (1 Jahr); es muss nirgends gespeichert oder erneuert werden. SANs aus `agent_api.server_names`. Der Server sendet Zertifikat und CA (Kette), damit der Agent beim Enrollment den Fingerprint prüfen kann. +- Konfiguration: `agent_api.listen` (Standard `:8444`), `agent_api.public_address` (`host:port` für den angezeigten Enrollment-Befehl; Standard: Host aus `http.public_url` und Port aus `listen`), `agent_api.server_names` (Standard: Host aus `public_address`). +- **Client-Zertifikate:** 14 Tage, CN = Agent-ID, URI-SAN `urn::agent:`, nur `ClientAuth`, zufällige 128-Bit-Seriennummer. Der Server speichert pro Agent **nur die Seriennummer des aktuellen Zertifikats**. Eine Verbindung wird nur akzeptiert, wenn Kette, Gültigkeit, Agent-ID, Seriennummer und Sperrstatus stimmen. Nach einer Erneuerung ist das alte Zertifikat damit sofort wertlos; eine Sperrliste ist nicht nötig. +- Der Listener fordert Client-Zertifikate an, prüft sie aber selbst (Interceptor), weil `Enroll` ohne Zertifikat und `RenewCertificate` mit einem bis zu 30 Tage abgelaufenen Zertifikat erlaubt sein müssen. +- **Enrollment-Token** `.`: ID 16 Hex-Zeichen, Secret 32 Zufallsbytes (base64url). In der Datenbank liegt SHA-256 des Secrets (hohe Entropie, kein Passwort-Hash nötig). Einmalig, an die Rolle gebunden, Standard 24 h gültig. Fehlversuche werden pro Quell-IP begrenzt. +- Der Agent erneuert sein Zertifikat, sobald weniger als die Hälfte der Laufzeit übrig ist, mit neuem Schlüssel. +- `Connect`: Agent sendet `Hello`, Server antwortet mit `Welcome` (Modus, Pull-Intervall Standard 60 s, Heartbeat Standard 30 s, Soll-Revision). Neue Revisionen werden verbundenen Agents im Modus `auto_pull` sofort als `ApplyRevisionCommand` gesendet; ohne Stream holt der Agent sie im Pull-Intervall über `GetDesiredState`. Ein gesperrter Agent wird sofort getrennt. + +### Agent + +- Konfigurationsdatei `/etc/ddi-suite/agent-.yaml` (`--config`): `state_dir`, `log`, Zeitlimits und der Abschnitt der Rolle (`dns:` bzw. `dhcp:`). Serveradresse, Servername, Agent-ID, Schlüssel und Zertifikate schreibt `enroll` nach `state_dir/identity/` (ADR 0011). +- Befehle: `enroll --server --token --ca-hash sha256: [--server-name ]`, `run`, `status`, `version`. +- **Ablauf** (siehe `agent/core/role.go`): Bundle prüfen (`bundle.Verify`, Revision > angewendete) → unter `revisions//` ablegen → `Validate` → `Activate` → `Health` (bis zu 30 s wiederholt) → bei Fehler `Activate(letzte funktionierende)`. Ergebnis `succeeded`, `rejected`, `rolled_back` oder `failed` geht per `ReportDeployment` an den Server; ist er nicht erreichbar, wird der Bericht gespeichert und später gesendet. +- Eine abgelehnte oder zurückgerollte Revision wird **nicht automatisch erneut** versucht (sonst Endlosschleife bei jedem Poll). Erst eine neuere Revision oder ein ausdrücklicher Push derselben Nummer startet einen neuen Versuch. +- Ohne Server läuft der Dienst mit der zuletzt aktiven Konfiguration weiter. +- **Leases:** Implementiert die Rolle `LeaseSource`, fragt der Kern sie alle 30 s ab und meldet Änderungen; beim Start, nach jeder neuen Verbindung und stündlich einen vollständigen Stand (`full_sync`). Die Sequenznummer ist persistent. + +### DNS-Rolle (BIND) + +- `conf_dir` (Standard `/etc/bind/ddi-suite`) erhält `ddi-suite.conf` und `ddi-suite-options.conf`, `zone_dir` (Standard `/var/lib/bind/ddi-suite`, muss der Generator-Einstellung entsprechen) erhält den Inhalt von `zones/`. `zone_dir` liegt unter `/var/lib/bind`, weil BIND dort schreiben darf (Journale dynamischer Zonen; AppArmor auf Ubuntu). +- `Validate`: `named-checkconf` auf einer temporären `named.conf`, die die neuen Include-Dateien einbindet, und `named-checkzone` für jede Zonendatei (Zonenname aus `db.`). +- `Activate`: bisherige Dateien sichern, neue atomar schreiben, veraltete entfernen, `named-checkconf -z` auf die echte `named.conf`; schlägt das fehl, Sicherung zurück und Fehler. Danach `rndc reconfig` und `rndc reload`. +- `Health`: `rndc status` und SOA-Abfrage jeder verwalteten Zone an `dns_address` (Standard `127.0.0.1:53`); die Seriennummer muss der Revision entsprechen. + +### DHCP-Rolle (Kea) + +- `config_file` (Standard `/etc/kea/kea-dhcp4.conf`), `control_socket` (muss der Generator-Einstellung entsprechen). +- `Validate`: `kea-dhcp4 -t` auf die neue Datei. `Activate`: Sicherung, atomar schreiben, `config-reload` über den Control-Socket, bei Fehler Sicherung zurück. `Health`: `status-get`. Leases: `lease4-get-page`. + +## Konsequenzen + +- Server, Agent-Kern und Rollen lassen sich unabhängig entwickeln und testen; der Kern kennt weder BIND noch Kea. +- Zielpfade stehen zweimal (Generator-Einstellung und Agent-Konfiguration). Der Installer (M6) setzt beide gleich; die Rollen prüfen beim `Validate`, dass die erzeugte Konfiguration ihr `zone_dir` bzw. ihren `control_socket` verwendet, und lehnen sonst mit klarer Meldung ab. +- Schlüsselrotation des Bundle-Signaturschlüssels (ADR 0009) und CA-Rotation sind vorbereitet, aber nicht umgesetzt. diff --git a/docs/adr/README.md b/docs/adr/README.md index 6e3f22e..6721ea3 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -24,3 +24,4 @@ Jede Grundsatzentscheidung wird hier als kurze ADR festgehalten. Format siehe [0 | [0018](0018-zentrale-benennung.md) | Zentrale Benennung von Produkt, Pfaden und Diensten | Akzeptiert | | [0019](0019-openapi-vertragstests.md) | OpenAPI: Vertragstests statt generierter Server-Stubs | Akzeptiert | | [0020](0020-authentifizierung-sitzungen.md) | Authentifizierung, Sitzungen und Rollen | Akzeptiert | +| [0021](0021-agent-strecke-m3.md) | Agent-Strecke: Revisionen, PKI, Ablauf im Agent (M3) | Akzeptiert | diff --git a/internal/agentpki/agentpki.go b/internal/agentpki/agentpki.go new file mode 100644 index 0000000..7e67900 --- /dev/null +++ b/internal/agentpki/agentpki.go @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package agentpki holds the conventions shared by server and agents for the +// agent certificates (docs/adr/0008-interne-ca-enrollment.md): the identity +// URI in the client certificate, the CA fingerprint used to bootstrap trust +// during enrollment and the TLS settings of the agent connection. +package agentpki + +import ( + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "encoding/hex" + "errors" + "fmt" + "net/url" + "regexp" + "strings" + + "git.beugsiud.de/beug.k/DDI/internal/brand" +) + +// uuidPattern matches the canonical lowercase UUID form used for agent IDs. +var uuidPattern = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`) + +// ValidAgentID reports whether id is a canonical lowercase UUID. +func ValidAgentID(id string) bool { return uuidPattern.MatchString(id) } + +// uriPrefix is "urn::agent:". +func uriPrefix() string { return "urn:" + brand.Product + ":agent:" } + +// AgentURI returns the URI SAN that identifies an agent in its client +// certificate, e.g. "urn:ddi-suite:agent:". +func AgentURI(agentID string) *url.URL { + return &url.URL{Scheme: "urn", Opaque: strings.TrimPrefix(uriPrefix(), "urn:") + agentID} +} + +// AgentID extracts the agent ID from a client certificate. The certificate +// must carry exactly one agent URI SAN and its common name must match. +func AgentID(cert *x509.Certificate) (string, error) { + var id string + for _, u := range cert.URIs { + s := u.String() + if rest, ok := strings.CutPrefix(s, uriPrefix()); ok { + if id != "" { + return "", errors.New("certificate carries more than one agent ID") + } + id = rest + } + } + switch { + case id == "": + return "", errors.New("certificate carries no agent ID") + case !ValidAgentID(id): + return "", fmt.Errorf("certificate carries an invalid agent ID %q", id) + case cert.Subject.CommonName != id: + return "", errors.New("certificate common name does not match the agent ID") + } + return id, nil +} + +// FingerprintPrefix precedes the hex digest in a CA fingerprint. +const FingerprintPrefix = "sha256:" + +// Fingerprint returns "sha256:" of the certificate's DER encoding. The +// enrollment command carries the fingerprint of the agent CA. +func Fingerprint(cert *x509.Certificate) string { + sum := sha256.Sum256(cert.Raw) + return FingerprintPrefix + hex.EncodeToString(sum[:]) +} + +// ParseFingerprint validates a fingerprint and returns it in canonical +// (lowercase) form. +func ParseFingerprint(s string) (string, error) { + hexPart, ok := strings.CutPrefix(strings.TrimSpace(s), FingerprintPrefix) + if !ok { + return "", fmt.Errorf("CA fingerprint must start with %q", FingerprintPrefix) + } + hexPart = strings.ToLower(hexPart) + if b, err := hex.DecodeString(hexPart); err != nil || len(b) != sha256.Size { + return "", errors.New("CA fingerprint must be 64 hex digits") + } + return FingerprintPrefix + hexPart, nil +} + +// VerifyChainWithFingerprint is used by the agent during enrollment, before +// it has the CA certificate: rawCerts (as sent by the server, leaf first) +// must contain a self-signed CA whose fingerprint equals want, and the leaf +// must chain to it and be valid for serverName. It is meant for +// tls.Config.VerifyPeerCertificate together with InsecureSkipVerify. +func VerifyChainWithFingerprint(rawCerts [][]byte, want, serverName string) (*x509.Certificate, error) { + want, err := ParseFingerprint(want) + if err != nil { + return nil, err + } + if len(rawCerts) < 2 { + return nil, errors.New("server did not send its CA certificate") + } + certs := make([]*x509.Certificate, len(rawCerts)) + for i, raw := range rawCerts { + if certs[i], err = x509.ParseCertificate(raw); err != nil { + return nil, fmt.Errorf("parse server certificate: %w", err) + } + } + var ca *x509.Certificate + for _, c := range certs[1:] { + if Fingerprint(c) == want { + ca = c + break + } + } + if ca == nil { + return nil, errors.New("server CA does not match the expected fingerprint") + } + roots := x509.NewCertPool() + roots.AddCert(ca) + if _, err := certs[0].Verify(x509.VerifyOptions{Roots: roots, DNSName: serverName}); err != nil { + return nil, fmt.Errorf("verify server certificate: %w", err) + } + return ca, nil +} + +// ClientTLSConfig returns the TLS settings agents use after enrollment. +func ClientTLSConfig(ca *x509.Certificate, cert tls.Certificate, serverName string) *tls.Config { + roots := x509.NewCertPool() + roots.AddCert(ca) + return &tls.Config{ + MinVersion: tls.VersionTLS13, + RootCAs: roots, + Certificates: []tls.Certificate{cert}, + ServerName: serverName, + } +} diff --git a/internal/agentpki/agentpki_test.go b/internal/agentpki/agentpki_test.go new file mode 100644 index 0000000..d74c33b --- /dev/null +++ b/internal/agentpki/agentpki_test.go @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: Apache-2.0 + +package agentpki + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "math/big" + "net/url" + "strings" + "testing" + "time" +) + +const testID = "0b6f3c1e-5d2a-4c8e-9f10-123456789abc" + +func newCA(t *testing.T) (*x509.Certificate, *ecdsa.PrivateKey) { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "test CA"}, + NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), + IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + cert, _ := x509.ParseCertificate(der) + return cert, key +} + +func issue(t *testing.T, ca *x509.Certificate, caKey *ecdsa.PrivateKey, tmpl *x509.Certificate) *x509.Certificate { + t.Helper() + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + tmpl.SerialNumber = big.NewInt(2) + tmpl.NotBefore = time.Now().Add(-time.Hour) + tmpl.NotAfter = time.Now().Add(time.Hour) + der, err := x509.CreateCertificate(rand.Reader, tmpl, ca, &key.PublicKey, caKey) + if err != nil { + t.Fatal(err) + } + cert, _ := x509.ParseCertificate(der) + return cert +} + +func TestAgentID(t *testing.T) { + if got := AgentURI(testID).String(); got != "urn:ddi-suite:agent:"+testID { + t.Fatalf("AgentURI = %s", got) + } + ca, key := newCA(t) + good := issue(t, ca, key, &x509.Certificate{Subject: pkix.Name{CommonName: testID}, URIs: []*url.URL{AgentURI(testID)}}) + if id, err := AgentID(good); err != nil || id != testID { + t.Fatalf("AgentID = %q, %v", id, err) + } + other := "11111111-2222-4333-8444-555555555555" + bad := map[string]*x509.Certificate{ + "none": issue(t, ca, key, &x509.Certificate{Subject: pkix.Name{CommonName: testID}}), + "cn": issue(t, ca, key, &x509.Certificate{Subject: pkix.Name{CommonName: other}, URIs: []*url.URL{AgentURI(testID)}}), + "two": issue(t, ca, key, &x509.Certificate{Subject: pkix.Name{CommonName: testID}, URIs: []*url.URL{AgentURI(testID), AgentURI(other)}}), + "invalid": issue(t, ca, key, &x509.Certificate{Subject: pkix.Name{CommonName: "X"}, URIs: []*url.URL{AgentURI("X")}}), + "uppercase": issue(t, ca, key, &x509.Certificate{Subject: pkix.Name{CommonName: strings.ToUpper(testID)}, + URIs: []*url.URL{AgentURI(strings.ToUpper(testID))}}), + } + for name, c := range bad { + if _, err := AgentID(c); err == nil { + t.Errorf("%s: accepted", name) + } + } +} + +func TestFingerprint(t *testing.T) { + ca, _ := newCA(t) + fp := Fingerprint(ca) + if !strings.HasPrefix(fp, "sha256:") || len(fp) != 7+64 { + t.Fatalf("fingerprint %q", fp) + } + if got, err := ParseFingerprint(" " + strings.ToUpper(fp[7:]) + " "); err == nil || got != "" { + t.Errorf("accepted fingerprint without prefix: %q", got) + } + if got, err := ParseFingerprint("sha256:" + strings.ToUpper(fp[7:])); err != nil || got != fp { + t.Errorf("ParseFingerprint = %q, %v", got, err) + } + for _, s := range []string{"sha256:", "sha256:abc", "sha256:" + strings.Repeat("z", 64), "md5:" + fp[7:]} { + if _, err := ParseFingerprint(s); err == nil { + t.Errorf("%q accepted", s) + } + } +} + +func TestVerifyChainWithFingerprint(t *testing.T) { + ca, key := newCA(t) + otherCA, otherKey := newCA(t) + leaf := issue(t, ca, key, &x509.Certificate{ + Subject: pkix.Name{CommonName: "ddi.example.test"}, DNSNames: []string{"ddi.example.test"}, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + }) + foreign := issue(t, otherCA, otherKey, &x509.Certificate{ + Subject: pkix.Name{CommonName: "ddi.example.test"}, DNSNames: []string{"ddi.example.test"}, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + }) + fp := Fingerprint(ca) + + got, err := VerifyChainWithFingerprint([][]byte{leaf.Raw, ca.Raw}, fp, "ddi.example.test") + if err != nil || !got.Equal(ca) { + t.Fatalf("valid chain: %v", err) + } + cases := map[string]struct { + raw [][]byte + fp string + name string + }{ + "leaf only": {[][]byte{leaf.Raw}, fp, "ddi.example.test"}, + "wrong CA": {[][]byte{leaf.Raw, otherCA.Raw}, fp, "ddi.example.test"}, + "leaf of other": {[][]byte{foreign.Raw, ca.Raw}, fp, "ddi.example.test"}, + "wrong name": {[][]byte{leaf.Raw, ca.Raw}, fp, "evil.example.test"}, + "bad fingerprint": {[][]byte{leaf.Raw, ca.Raw}, "sha256:00", "ddi.example.test"}, + "garbage": {[][]byte{leaf.Raw, []byte("x")}, fp, "ddi.example.test"}, + } + for name, c := range cases { + if _, err := VerifyChainWithFingerprint(c.raw, c.fp, c.name); err == nil { + t.Errorf("%s: accepted", name) + } + } +} diff --git a/internal/bundle/bundle.go b/internal/bundle/bundle.go new file mode 100644 index 0000000..0bda03a --- /dev/null +++ b/internal/bundle/bundle.go @@ -0,0 +1,211 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package bundle defines the signed configuration bundle exchanged between the +// management server and the agents (docs/adr/0009-signierte-config-bundles.md). +// +// A bundle is a JSON manifest, an Ed25519 signature over the exact manifest +// bytes and the artifact contents. The server signs with [Sign]; the agent +// checks with [Verify] before it parses or stores anything. +package bundle + +import ( + "bytes" + "crypto/ed25519" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "path" + "strings" + "time" +) + +// FormatVersion is the manifest format produced by this package. +const FormatVersion = 1 + +// Limits that keep a malicious or broken bundle from exhausting the agent. +const ( + MaxFiles = 100_000 + MaxPathLen = 255 + MaxTotalSize = 512 << 20 +) + +// Manifest describes one revision for one agent. +type Manifest struct { + Format int `json:"format"` + AgentID string `json:"agent_id"` + Role string `json:"role"` + Revision uint64 `json:"revision"` + CreatedAt time.Time `json:"created_at"` + Files []File `json:"files"` +} + +// File describes one artifact. +type File struct { + // Path is relative to the role's configuration root, slash separated + // (see [CheckPath]). + Path string `json:"path"` + // Mode holds the Unix permission bits (e.g. 0o640). + Mode uint32 `json:"mode"` + // SHA256 is the lowercase hex digest of the content. + SHA256 string `json:"sha256"` + Size int64 `json:"size"` + // CreateOnly files are written only if they do not exist yet and are + // never overwritten or removed afterwards (initial files of dynamic + // zones). + CreateOnly bool `json:"create_only,omitempty"` +} + +// Artifact is a file with its content, the input of [Sign]. +type Artifact struct { + Path string + Mode uint32 + Content []byte + CreateOnly bool +} + +// Sign builds the manifest for the artifacts and signs it. The returned +// manifest bytes must be transmitted unchanged. +func Sign(key ed25519.PrivateKey, agentID, role string, revision uint64, createdAt time.Time, arts []Artifact) (manifest, signature []byte, err error) { + m := Manifest{ + Format: FormatVersion, AgentID: agentID, Role: role, Revision: revision, + CreatedAt: createdAt.UTC(), Files: make([]File, len(arts)), + } + for i, a := range arts { + sum := sha256.Sum256(a.Content) + m.Files[i] = File{ + Path: a.Path, Mode: a.Mode, SHA256: hex.EncodeToString(sum[:]), + Size: int64(len(a.Content)), CreateOnly: a.CreateOnly, + } + } + if err := m.check(); err != nil { + return nil, nil, err + } + manifest, err = json.Marshal(m) + if err != nil { + return nil, nil, fmt.Errorf("encode manifest: %w", err) + } + if len(key) != ed25519.PrivateKeySize { + return nil, nil, errors.New("invalid signing key") + } + return manifest, ed25519.Sign(key, manifest), nil +} + +// Expect lists what the verifier requires of a bundle. +type Expect struct { + AgentID string + Role string + // MinRevision rejects bundles with a revision lower than this value + // (replay protection; 0 accepts every revision). + MinRevision uint64 +} + +// Verify checks the signature over the exact manifest bytes, then the +// manifest itself (format, agent, role, revision, paths) and finally that +// contents holds exactly the listed files with matching size and digest. +// contents maps the manifest path to the file content. +func Verify(pub ed25519.PublicKey, manifest, signature []byte, contents map[string][]byte, want Expect) (*Manifest, error) { + if len(pub) != ed25519.PublicKeySize { + return nil, errors.New("invalid bundle signing key") + } + if !ed25519.Verify(pub, manifest, signature) { + return nil, errors.New("bundle signature is invalid") + } + var m Manifest + dec := json.NewDecoder(bytes.NewReader(manifest)) + dec.DisallowUnknownFields() + if err := dec.Decode(&m); err != nil { + return nil, fmt.Errorf("decode manifest: %w", err) + } + if dec.More() { + return nil, errors.New("decode manifest: trailing data") + } + switch { + case m.Format != FormatVersion: + return nil, fmt.Errorf("unsupported manifest format %d", m.Format) + case m.AgentID != want.AgentID: + return nil, fmt.Errorf("bundle is for agent %q, not %q", m.AgentID, want.AgentID) + case m.Role != want.Role: + return nil, fmt.Errorf("bundle is for role %q, not %q", m.Role, want.Role) + case m.Revision == 0: + return nil, errors.New("bundle has no revision") + case m.Revision < want.MinRevision: + return nil, fmt.Errorf("bundle revision %d is older than %d", m.Revision, want.MinRevision) + } + if err := m.check(); err != nil { + return nil, err + } + if len(contents) != len(m.Files) { + return nil, fmt.Errorf("bundle has %d files, manifest lists %d", len(contents), len(m.Files)) + } + for _, f := range m.Files { + c, ok := contents[f.Path] + if !ok { + return nil, fmt.Errorf("file %s is missing", f.Path) + } + if int64(len(c)) != f.Size { + return nil, fmt.Errorf("file %s: size %d, manifest says %d", f.Path, len(c), f.Size) + } + sum := sha256.Sum256(c) + if hex.EncodeToString(sum[:]) != f.SHA256 { + return nil, fmt.Errorf("file %s: SHA-256 mismatch", f.Path) + } + } + return &m, nil +} + +// check validates the file list: count, total size, paths, duplicates and +// modes. +func (m *Manifest) check() error { + if len(m.Files) > MaxFiles { + return fmt.Errorf("too many files (%d)", len(m.Files)) + } + seen := make(map[string]bool, len(m.Files)) + var total int64 + for _, f := range m.Files { + if err := CheckPath(f.Path); err != nil { + return err + } + if seen[f.Path] { + return fmt.Errorf("duplicate file %s", f.Path) + } + seen[f.Path] = true + if f.Mode&^0o777 != 0 { + return fmt.Errorf("file %s: invalid mode %o", f.Path, f.Mode) + } + if f.Size < 0 { + return fmt.Errorf("file %s: negative size", f.Path) + } + total += f.Size + if total > MaxTotalSize { + return errors.New("bundle exceeds the size limit") + } + } + return nil +} + +// CheckPath accepts clean, relative, slash-separated paths of the characters +// A-Z a-z 0-9 . _ - + @ whose elements do not start with a dot. Agents can +// therefore join them to their root directory without escaping it. +func CheckPath(p string) error { + if p == "" || len(p) > MaxPathLen { + return fmt.Errorf("invalid file path %q", p) + } + if path.IsAbs(p) || path.Clean(p) != p { + return fmt.Errorf("file path %q is not clean and relative", p) + } + for _, el := range strings.Split(p, "/") { + if el == "" || el[0] == '.' { + return fmt.Errorf("invalid element in file path %q", p) + } + for i := 0; i < len(el); i++ { + c := el[i] + if (c < 'a' || c > 'z') && (c < 'A' || c > 'Z') && (c < '0' || c > '9') && + c != '.' && c != '_' && c != '-' && c != '+' && c != '@' { + return fmt.Errorf("invalid character in file path %q", p) + } + } + } + return nil +} diff --git a/internal/bundle/bundle_test.go b/internal/bundle/bundle_test.go new file mode 100644 index 0000000..ff3ec41 --- /dev/null +++ b/internal/bundle/bundle_test.go @@ -0,0 +1,172 @@ +// SPDX-License-Identifier: Apache-2.0 + +package bundle + +import ( + "bytes" + "crypto/ed25519" + "strings" + "testing" + "time" +) + +var testTime = time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC) + +func testKey(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) { + t.Helper() + seed := bytes.Repeat([]byte{7}, ed25519.SeedSize) + priv := ed25519.NewKeyFromSeed(seed) + return priv.Public().(ed25519.PublicKey), priv +} + +func sample() []Artifact { + return []Artifact{ + {Path: "ddi-suite.conf", Mode: 0o640, Content: []byte("zone \"a.test\" {};\n")}, + {Path: "zones/db.a.test", Mode: 0o640, Content: []byte("$TTL 3600\n")}, + {Path: "zones/db.dyn.test", Mode: 0o640, Content: []byte("dyn\n"), CreateOnly: true}, + } +} + +func contents(arts []Artifact) map[string][]byte { + m := make(map[string][]byte, len(arts)) + for _, a := range arts { + m[a.Path] = a.Content + } + return m +} + +func TestSignVerifyRoundTrip(t *testing.T) { + pub, priv := testKey(t) + arts := sample() + man, sig, err := Sign(priv, "agent-1", "dns", 42, testTime, arts) + if err != nil { + t.Fatal(err) + } + m, err := Verify(pub, man, sig, contents(arts), Expect{AgentID: "agent-1", Role: "dns", MinRevision: 42}) + if err != nil { + t.Fatal(err) + } + if m.Revision != 42 || len(m.Files) != 3 || !m.Files[2].CreateOnly || m.Files[0].Mode != 0o640 || + !m.CreatedAt.Equal(testTime) || m.Files[1].Size != int64(len(arts[1].Content)) { + t.Errorf("manifest %+v", m) + } + // Signing is deterministic: same input, same bytes. + man2, sig2, _ := Sign(priv, "agent-1", "dns", 42, testTime, arts) + if !bytes.Equal(man, man2) || !bytes.Equal(sig, sig2) { + t.Error("signing is not deterministic") + } +} + +func TestVerifyRejects(t *testing.T) { + pub, priv := testKey(t) + arts := sample() + man, sig, err := Sign(priv, "agent-1", "dns", 5, testTime, arts) + if err != nil { + t.Fatal(err) + } + ok := Expect{AgentID: "agent-1", Role: "dns"} + otherPub, _, _ := ed25519.GenerateKey(nil) + + tampered := bytes.Replace(man, []byte(`"revision":5`), []byte(`"revision":6`), 1) + changed := contents(arts) + changed["zones/db.a.test"] = []byte("$TTL 3601\n") + missing := contents(arts) + delete(missing, "zones/db.a.test") + extra := contents(arts) + extra["other"] = nil + short := contents(arts) + short["zones/db.a.test"] = []byte("x") + + cases := []struct { + name string + pub ed25519.PublicKey + man, sig []byte + files map[string][]byte + want Expect + msg string + }{ + {"wrong key", otherPub, man, sig, contents(arts), ok, "signature"}, + {"bad key length", pub[:5], man, sig, contents(arts), ok, "signing key"}, + {"tampered manifest", pub, tampered, sig, contents(arts), ok, "signature"}, + {"other agent", pub, man, sig, contents(arts), Expect{AgentID: "agent-2", Role: "dns"}, "agent"}, + {"other role", pub, man, sig, contents(arts), Expect{AgentID: "agent-1", Role: "dhcp"}, "role"}, + {"replay", pub, man, sig, contents(arts), Expect{AgentID: "agent-1", Role: "dns", MinRevision: 6}, "older"}, + {"changed content", pub, man, sig, changed, ok, "SHA-256"}, + {"wrong size", pub, man, sig, short, ok, "size"}, + {"missing file", pub, man, sig, missing, ok, "files"}, + {"extra file", pub, man, sig, extra, ok, "files"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + _, err := Verify(c.pub, c.man, c.sig, c.files, c.want) + if err == nil || !strings.Contains(err.Error(), c.msg) { + t.Errorf("err = %v, want %q", err, c.msg) + } + }) + } +} + +func TestVerifyRejectsUnsafeManifests(t *testing.T) { + pub, priv := testKey(t) + sign := func(raw string) ([]byte, []byte) { return []byte(raw), ed25519.Sign(priv, []byte(raw)) } + cases := map[string]string{ + "format": `{"format":2,"agent_id":"a","role":"dns","revision":1,"created_at":"2026-10-13T12:00:00Z","files":[]}`, + "no rev": `{"format":1,"agent_id":"a","role":"dns","revision":0,"created_at":"2026-10-13T12:00:00Z","files":[]}`, + "unknown": `{"format":1,"agent_id":"a","role":"dns","revision":1,"created_at":"2026-10-13T12:00:00Z","files":[],"x":1}`, + "trailing": `{"format":1,"agent_id":"a","role":"dns","revision":1,"created_at":"2026-10-13T12:00:00Z","files":[]} {}`, + "escape": `{"format":1,"agent_id":"a","role":"dns","revision":1,"created_at":"2026-10-13T12:00:00Z","files":[{"path":"../etc/passwd","mode":420,"sha256":"","size":0}]}`, + "mode": `{"format":1,"agent_id":"a","role":"dns","revision":1,"created_at":"2026-10-13T12:00:00Z","files":[{"path":"x","mode":2048,"sha256":"","size":0}]}`, + "dup": `{"format":1,"agent_id":"a","role":"dns","revision":1,"created_at":"2026-10-13T12:00:00Z","files":[{"path":"x","mode":420,"sha256":"","size":0},{"path":"x","mode":420,"sha256":"","size":0}]}`, + "negative": `{"format":1,"agent_id":"a","role":"dns","revision":1,"created_at":"2026-10-13T12:00:00Z","files":[{"path":"x","mode":420,"sha256":"","size":-1}]}`, + "huge": `{"format":1,"agent_id":"a","role":"dns","revision":1,"created_at":"2026-10-13T12:00:00Z","files":[{"path":"x","mode":420,"sha256":"","size":1073741824}]}`, + } + for name, raw := range cases { + t.Run(name, func(t *testing.T) { + man, sig := sign(raw) + if _, err := Verify(pub, man, sig, map[string][]byte{}, Expect{AgentID: "a", Role: "dns"}); err == nil { + t.Error("accepted") + } + }) + } + if _, _, err := Sign(priv[:3], "a", "dns", 1, testTime, nil); err == nil { + t.Error("Sign accepted a short key") + } + if _, _, err := Sign(priv, "a", "dns", 1, testTime, []Artifact{{Path: "/abs"}}); err == nil { + t.Error("Sign accepted an absolute path") + } +} + +func TestCheckPath(t *testing.T) { + good := []string{"ddi-suite.conf", "zones/db.office.test", "kea-dhcp4.conf", "a/b/c+d@e_f-g"} + bad := []string{"", "/abs", "../x", "a/../b", "a//b", "a/", ".hidden", "zones/.x", "a b", "a\\b", "ä", + strings.Repeat("a", MaxPathLen+1)} + for _, p := range good { + if err := CheckPath(p); err != nil { + t.Errorf("%q: %v", p, err) + } + } + for _, p := range bad { + if CheckPath(p) == nil { + t.Errorf("%q accepted", p) + } + } +} + +func FuzzVerify(f *testing.F) { + pub, priv := testKey(&testing.T{}) + man, _, _ := Sign(priv, "a", "dns", 1, testTime, sample()) + f.Add(man) + f.Fuzz(func(t *testing.T, raw []byte) { + // Whatever is signed, Verify must not panic and must never accept a + // path outside the root. + m, err := Verify(pub, raw, ed25519.Sign(priv, raw), map[string][]byte{}, Expect{AgentID: "a", Role: "dns"}) + if err != nil { + return + } + for _, file := range m.Files { + if CheckPath(file.Path) != nil { + t.Fatalf("accepted unsafe path %q", file.Path) + } + } + }) +} diff --git a/proto/ddi/agent/v1/agent.proto b/proto/ddi/agent/v1/agent.proto index 9eaa990..c63dd3a 100644 --- a/proto/ddi/agent/v1/agent.proto +++ b/proto/ddi/agent/v1/agent.proto @@ -122,11 +122,13 @@ message Hello { HealthStatus health = 4; // Version of the managed service, e.g. "BIND 9.20.3" or "Kea 3.2.1". string service_version = 5; + string hostname = 6; } message Heartbeat { uint64 applied_revision = 1; HealthStatus health = 2; + uint64 last_good_revision = 3; } message HealthStatus { @@ -147,6 +149,9 @@ message Welcome { OperatingMode mode = 1; google.protobuf.Duration pull_interval = 2; google.protobuf.Duration heartbeat_interval = 3; + // Revision the agent should run (0 = none). In auto_pull mode the agent + // applies it right away if it is newer than its applied revision. + uint64 desired_revision = 4; } // Command is pushed by the server over the Connect stream. diff --git a/proto/gen/ddi/agent/v1/agent.pb.go b/proto/gen/ddi/agent/v1/agent.pb.go index 8e0db07..ba3a469 100644 --- a/proto/gen/ddi/agent/v1/agent.pb.go +++ b/proto/gen/ddi/agent/v1/agent.pb.go @@ -784,6 +784,7 @@ type Hello struct { Health *HealthStatus `protobuf:"bytes,4,opt,name=health,proto3" json:"health,omitempty"` // Version of the managed service, e.g. "BIND 9.20.3" or "Kea 3.2.1". ServiceVersion string `protobuf:"bytes,5,opt,name=service_version,json=serviceVersion,proto3" json:"service_version,omitempty"` + Hostname string `protobuf:"bytes,6,opt,name=hostname,proto3" json:"hostname,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -853,12 +854,20 @@ func (x *Hello) GetServiceVersion() string { return "" } +func (x *Hello) GetHostname() string { + if x != nil { + return x.Hostname + } + return "" +} + type Heartbeat struct { - state protoimpl.MessageState `protogen:"open.v1"` - AppliedRevision uint64 `protobuf:"varint,1,opt,name=applied_revision,json=appliedRevision,proto3" json:"applied_revision,omitempty"` - Health *HealthStatus `protobuf:"bytes,2,opt,name=health,proto3" json:"health,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + AppliedRevision uint64 `protobuf:"varint,1,opt,name=applied_revision,json=appliedRevision,proto3" json:"applied_revision,omitempty"` + Health *HealthStatus `protobuf:"bytes,2,opt,name=health,proto3" json:"health,omitempty"` + LastGoodRevision uint64 `protobuf:"varint,3,opt,name=last_good_revision,json=lastGoodRevision,proto3" json:"last_good_revision,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *Heartbeat) Reset() { @@ -905,6 +914,13 @@ func (x *Heartbeat) GetHealth() *HealthStatus { return nil } +func (x *Heartbeat) GetLastGoodRevision() uint64 { + if x != nil { + return x.LastGoodRevision + } + return 0 +} + type HealthStatus struct { state protoimpl.MessageState `protogen:"open.v1"` State HealthState `protobuf:"varint,1,opt,name=state,proto3,enum=ddi.agent.v1.HealthState" json:"state,omitempty"` @@ -1039,8 +1055,11 @@ type Welcome struct { Mode OperatingMode `protobuf:"varint,1,opt,name=mode,proto3,enum=ddi.agent.v1.OperatingMode" json:"mode,omitempty"` PullInterval *durationpb.Duration `protobuf:"bytes,2,opt,name=pull_interval,json=pullInterval,proto3" json:"pull_interval,omitempty"` HeartbeatInterval *durationpb.Duration `protobuf:"bytes,3,opt,name=heartbeat_interval,json=heartbeatInterval,proto3" json:"heartbeat_interval,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Revision the agent should run (0 = none). In auto_pull mode the agent + // applies it right away if it is newer than its applied revision. + DesiredRevision uint64 `protobuf:"varint,4,opt,name=desired_revision,json=desiredRevision,proto3" json:"desired_revision,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *Welcome) Reset() { @@ -1094,6 +1113,13 @@ func (x *Welcome) GetHeartbeatInterval() *durationpb.Duration { return nil } +func (x *Welcome) GetDesiredRevision() uint64 { + if x != nil { + return x.DesiredRevision + } + return 0 +} + // Command is pushed by the server over the Connect stream. type Command struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -1982,16 +2008,18 @@ const file_ddi_agent_v1_agent_proto_rawDesc = "" + "\x0fConnectResponse\x121\n" + "\awelcome\x18\x01 \x01(\v2\x15.ddi.agent.v1.WelcomeH\x00R\awelcome\x121\n" + "\acommand\x18\x02 \x01(\v2\x15.ddi.agent.v1.CommandH\x00R\acommandB\t\n" + - "\amessage\"\xe2\x01\n" + + "\amessage\"\xfe\x01\n" + "\x05Hello\x12#\n" + "\ragent_version\x18\x01 \x01(\tR\fagentVersion\x12)\n" + "\x10applied_revision\x18\x02 \x01(\x04R\x0fappliedRevision\x12,\n" + "\x12last_good_revision\x18\x03 \x01(\x04R\x10lastGoodRevision\x122\n" + "\x06health\x18\x04 \x01(\v2\x1a.ddi.agent.v1.HealthStatusR\x06health\x12'\n" + - "\x0fservice_version\x18\x05 \x01(\tR\x0eserviceVersion\"j\n" + + "\x0fservice_version\x18\x05 \x01(\tR\x0eserviceVersion\x12\x1a\n" + + "\bhostname\x18\x06 \x01(\tR\bhostname\"\x98\x01\n" + "\tHeartbeat\x12)\n" + "\x10applied_revision\x18\x01 \x01(\x04R\x0fappliedRevision\x122\n" + - "\x06health\x18\x02 \x01(\v2\x1a.ddi.agent.v1.HealthStatusR\x06health\"\xc7\x01\n" + + "\x06health\x18\x02 \x01(\v2\x1a.ddi.agent.v1.HealthStatusR\x06health\x12,\n" + + "\x12last_good_revision\x18\x03 \x01(\x04R\x10lastGoodRevision\"\xc7\x01\n" + "\fHealthStatus\x12/\n" + "\x05state\x18\x01 \x01(\x0e2\x19.ddi.agent.v1.HealthStateR\x05state\x12\x18\n" + "\amessage\x18\x02 \x01(\tR\amessage\x129\n" + @@ -2001,11 +2029,12 @@ const file_ddi_agent_v1_agent_proto_rawDesc = "" + "\vHealthCheck\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12/\n" + "\x05state\x18\x02 \x01(\x0e2\x19.ddi.agent.v1.HealthStateR\x05state\x12\x18\n" + - "\amessage\x18\x03 \x01(\tR\amessage\"\xc4\x01\n" + + "\amessage\x18\x03 \x01(\tR\amessage\"\xef\x01\n" + "\aWelcome\x12/\n" + "\x04mode\x18\x01 \x01(\x0e2\x1b.ddi.agent.v1.OperatingModeR\x04mode\x12>\n" + "\rpull_interval\x18\x02 \x01(\v2\x19.google.protobuf.DurationR\fpullInterval\x12H\n" + - "\x12heartbeat_interval\x18\x03 \x01(\v2\x19.google.protobuf.DurationR\x11heartbeatInterval\"\xd5\x01\n" + + "\x12heartbeat_interval\x18\x03 \x01(\v2\x19.google.protobuf.DurationR\x11heartbeatInterval\x12)\n" + + "\x10desired_revision\x18\x04 \x01(\x04R\x0fdesiredRevision\"\xd5\x01\n" + "\aCommand\x12\x1d\n" + "\n" + "command_id\x18\x01 \x01(\tR\tcommandId\x12K\n" + diff --git a/server/api/openapi.yaml b/server/api/openapi.yaml index a743aa7..dd618f2 100644 --- a/server/api/openapi.yaml +++ b/server/api/openapi.yaml @@ -25,6 +25,9 @@ tags: - name: networks - name: addresses - name: config + - name: agents + - name: revisions + - name: leases paths: /system/info: @@ -501,6 +504,223 @@ paths: schema: { $ref: "#/components/schemas/ConfigPreview" } default: { $ref: "#/components/responses/Problem" } + /agents: + get: + tags: [agents] + operationId: listAgents + responses: + "200": + description: All enrolled agents (including revoked ones), sorted by role and hostname. + content: + application/json: + schema: + type: object + additionalProperties: false + required: [items] + properties: + items: { type: array, items: { $ref: "#/components/schemas/Agent" } } + default: { $ref: "#/components/responses/Problem" } + + /agents/{id}: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + get: + tags: [agents] + operationId: getAgent + responses: + "200": + description: The agent. + content: + application/json: + schema: { $ref: "#/components/schemas/Agent" } + default: { $ref: "#/components/responses/Problem" } + delete: + tags: [agents] + operationId: deleteAgent + description: Revokes the agent's certificate (if not yet revoked) and removes it with its deployments and leases. + responses: + "204": { description: Deleted. } + default: { $ref: "#/components/responses/Problem" } + + /agents/{id}/revoke: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + post: + tags: [agents] + operationId: revokeAgent + description: | + Revokes the agent's certificate. An open connection is closed + immediately; the agent needs a new enrollment to connect again. + responses: + "200": + description: The revoked agent. + content: + application/json: + schema: { $ref: "#/components/schemas/Agent" } + default: { $ref: "#/components/responses/Problem" } + + /enrollment-tokens: + get: + tags: [agents] + operationId: listEnrollmentTokens + responses: + "200": + description: Tokens that are neither used nor expired for more than 7 days, newest first. + content: + application/json: + schema: + type: object + additionalProperties: false + required: [items] + properties: + items: { type: array, items: { $ref: "#/components/schemas/EnrollmentToken" } } + default: { $ref: "#/components/responses/Problem" } + post: + tags: [agents] + operationId: createEnrollmentToken + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + required: [role] + properties: + role: { $ref: "#/components/schemas/AgentRole" } + description: { type: string, maxLength: 200 } + valid_hours: { type: integer, minimum: 1, maximum: 720, description: "Default 24." } + responses: + "201": + description: Created. The secret is returned only once. + content: + application/json: + schema: { $ref: "#/components/schemas/EnrollmentTokenCreated" } + default: { $ref: "#/components/responses/Problem" } + + /enrollment-tokens/{id}: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + delete: + tags: [agents] + operationId: deleteEnrollmentToken + responses: + "204": { description: Deleted. } + default: { $ref: "#/components/responses/Problem" } + + /revisions: + get: + tags: [revisions] + operationId: listRevisions + parameters: + - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 500 } } + - { name: before, in: query, schema: { type: integer, minimum: 1 }, description: Return revisions with a lower number. } + responses: + "200": + description: Revisions, newest first. + content: + application/json: + schema: + type: object + additionalProperties: false + required: [items, next_before] + properties: + items: { type: array, items: { $ref: "#/components/schemas/RevisionSummary" } } + next_before: { type: integer, nullable: true } + default: { $ref: "#/components/responses/Problem" } + post: + tags: [revisions] + operationId: createRevision + description: | + Generates the configuration from the current data and stores it as a + new, immutable revision. Agents in auto_pull mode are notified and + apply it. If the generated files equal the latest revision, no new + revision is created and the latest one is returned with 200. If DNS + or DHCP cannot be generated, nothing is stored (422, code + dns_settings_missing, config_invalid or config_failed; params as in + GeneratorProblem plus "part": "dns" or "dhcp"). + requestBody: + content: + application/json: + schema: + type: object + additionalProperties: false + properties: + comment: { type: string, maxLength: 500 } + responses: + "200": + description: Nothing changed; the latest revision. + content: + application/json: + schema: { $ref: "#/components/schemas/Revision" } + "201": + description: Created. + content: + application/json: + schema: { $ref: "#/components/schemas/Revision" } + default: { $ref: "#/components/responses/Problem" } + + /revisions/{number}: + parameters: + - { name: number, in: path, required: true, schema: { type: integer, minimum: 1 } } + get: + tags: [revisions] + operationId: getRevision + responses: + "200": + description: The revision with all files. + content: + application/json: + schema: { $ref: "#/components/schemas/Revision" } + default: { $ref: "#/components/responses/Problem" } + + /deployments: + get: + tags: [revisions] + operationId: listDeployments + parameters: + - { name: agent_id, in: query, schema: { type: string } } + - { name: revision, in: query, schema: { type: integer, minimum: 1 } } + - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 500 } } + - { name: before_id, in: query, schema: { type: integer, minimum: 1 } } + responses: + "200": + description: Deployment results reported by agents, newest first. + content: + application/json: + schema: + type: object + additionalProperties: false + required: [items, next_before_id] + properties: + items: { type: array, items: { $ref: "#/components/schemas/Deployment" } } + next_before_id: { type: integer, nullable: true } + default: { $ref: "#/components/responses/Problem" } + + /leases: + get: + tags: [leases] + operationId: listLeases + parameters: + - { name: network_id, in: query, schema: { type: string } } + - { name: state, in: query, schema: { $ref: "#/components/schemas/LeaseState" } } + - { name: q, in: query, schema: { type: string }, description: Substring of IP, MAC or hostname. } + - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 1000 } } + - { name: after, in: query, schema: { type: string }, description: Return leases with a higher IP (cursor). } + responses: + "200": + description: Leases reported by the DHCP agents, sorted by IP. + content: + application/json: + schema: + type: object + additionalProperties: false + required: [items, next_after] + properties: + items: { type: array, items: { $ref: "#/components/schemas/Lease" } } + next_after: { type: string, nullable: true } + default: { $ref: "#/components/responses/Problem" } + /networks: get: tags: [networks] @@ -1242,7 +1462,7 @@ components: dns: type: object additionalProperties: false - required: [primary_ns, hostmaster, name_servers, default_ttl, refresh, retry, expire, minimum, recursion, allow_recursion, forwarders] + required: [primary_ns, hostmaster, name_servers, default_ttl, refresh, retry, expire, minimum, recursion, allow_recursion, forwarders, zone_dir] properties: primary_ns: { type: string } hostmaster: { type: string, description: "Mailbox (hostmaster@example.com) or domain form." } @@ -1263,14 +1483,18 @@ components: recursion: { type: boolean } allow_recursion: { type: array, items: { type: string } } forwarders: { type: array, items: { type: string } } + zone_dir: { type: string, description: "Zone file directory on the DNS hosts (must match the DNS agent configuration)." } dhcp: type: object additionalProperties: false - required: [interfaces, direct_clients, valid_lifetime] + required: [interfaces, direct_clients, valid_lifetime, control_socket, lease_file, hooks_dir] properties: interfaces: { type: array, items: { type: string } } direct_clients: { type: boolean } valid_lifetime: { type: integer, minimum: 0 } + control_socket: { type: string, description: "Kea control socket path on the DHCP hosts." } + lease_file: { type: string, description: "Kea memfile lease file on the DHCP hosts." } + hooks_dir: { type: string, description: "Directory of the Kea hook libraries; empty for the directory Kea was built with." } GeneratedFile: type: object @@ -1300,6 +1524,149 @@ components: dns_error: { $ref: "#/components/schemas/GeneratorProblem" } dhcp_error: { $ref: "#/components/schemas/GeneratorProblem" } + AgentRole: + type: string + enum: [dns, dhcp] + + AgentMode: + type: string + enum: [auto_pull, manual] + + HealthState: + type: string + enum: [unknown, ok, degraded, failed] + + AgentHealth: + type: object + additionalProperties: false + required: [state, message, checks] + properties: + state: { $ref: "#/components/schemas/HealthState" } + message: { type: string } + checked_at: { type: string, format: date-time } + checks: + type: array + items: + type: object + additionalProperties: false + required: [name, state, message] + properties: + name: { type: string } + state: { $ref: "#/components/schemas/HealthState" } + message: { type: string } + + Agent: + type: object + additionalProperties: false + required: [id, role, hostname, agent_version, service_version, connected, mode, enrolled_at, cert_expires_at, + applied_revision, last_good_revision, desired_revision, health] + properties: + id: { type: string } + role: { $ref: "#/components/schemas/AgentRole" } + hostname: { type: string } + agent_version: { type: string } + service_version: { type: string, description: "Version of BIND or Kea as reported by the agent." } + connected: { type: boolean, description: "Control stream currently open." } + remote_address: { type: string, description: "Address of the last connection." } + mode: { $ref: "#/components/schemas/AgentMode" } + enrolled_at: { type: string, format: date-time } + last_seen_at: { type: string, format: date-time } + cert_expires_at: { type: string, format: date-time } + applied_revision: { type: integer, description: "Active revision on the agent, 0 = none." } + last_good_revision: { type: integer } + desired_revision: { type: integer, description: "Revision the agent should run, 0 = none." } + health: { $ref: "#/components/schemas/AgentHealth" } + revoked_at: { type: string, format: date-time } + last_deployment: { $ref: "#/components/schemas/Deployment" } + + EnrollmentToken: + type: object + additionalProperties: false + required: [id, role, description, created_at, created_by_name, expires_at] + properties: + id: { type: string } + role: { $ref: "#/components/schemas/AgentRole" } + description: { type: string } + created_at: { type: string, format: date-time } + created_by_name: { type: string } + expires_at: { type: string, format: date-time } + used_at: { type: string, format: date-time } + agent_id: { type: string, description: "Agent enrolled with this token." } + + EnrollmentTokenCreated: + type: object + additionalProperties: false + required: [token, secret, ca_fingerprint, server_address, command] + properties: + token: { $ref: "#/components/schemas/EnrollmentToken" } + secret: { type: string, description: "Complete one-time token \".\". Shown only once." } + ca_fingerprint: { type: string, description: "sha256: of the agent CA certificate (DER)." } + server_address: { type: string, description: "host:port of the agent API." } + command: { type: string, description: "Ready-to-run enrollment command for the agent host." } + + RevisionSummary: + type: object + additionalProperties: false + required: [number, created_at, created_by_name, comment, dns_files, dhcp_files, warnings] + properties: + number: { type: integer } + created_at: { type: string, format: date-time } + created_by_name: { type: string } + comment: { type: string } + dns_files: { type: integer } + dhcp_files: { type: integer } + warnings: { type: integer } + + Revision: + type: object + additionalProperties: false + required: [number, created_at, created_by_name, comment, dns, dhcp, warnings] + properties: + number: { type: integer } + created_at: { type: string, format: date-time } + created_by_name: { type: string } + comment: { type: string } + dns: { type: array, items: { $ref: "#/components/schemas/GeneratedFile" } } + dhcp: { type: array, items: { $ref: "#/components/schemas/GeneratedFile" } } + warnings: { type: array, items: { $ref: "#/components/schemas/GeneratorProblem" } } + + Deployment: + type: object + additionalProperties: false + required: [id, agent_id, agent_hostname, revision, trigger, outcome, message, active_revision, started_at, finished_at] + properties: + id: { type: integer } + agent_id: { type: string } + agent_hostname: { type: string } + revision: { type: integer } + trigger: { type: string, enum: [pull, push] } + outcome: { type: string, enum: [succeeded, rejected, rolled_back, failed] } + message: { type: string, description: "Details from the agent (English), e.g. checker output." } + active_revision: { type: integer } + started_at: { type: string, format: date-time } + finished_at: { type: string, format: date-time } + + LeaseState: + type: string + enum: [active, expired, released, declined] + + Lease: + type: object + additionalProperties: false + required: [ip, mac, hostname, state, subnet_id, agent_id, reported_at] + properties: + ip: { type: string } + mac: { type: string } + hostname: { type: string } + state: { $ref: "#/components/schemas/LeaseState" } + expires_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time, description: "Last client transaction (Kea cltt)." } + subnet_id: { type: integer } + network_id: { type: string, description: "Network whose Kea subnet ID matches." } + client_id: { type: string } + agent_id: { type: string } + reported_at: { type: string, format: date-time } + SavedFilter: type: object additionalProperties: false diff --git a/server/internal/httpapi/generator_test.go b/server/internal/httpapi/generator_test.go index 8ac638c..03d79e7 100644 --- a/server/internal/httpapi/generator_test.go +++ b/server/internal/httpapi/generator_test.go @@ -25,7 +25,8 @@ func TestGeneratorSettingsAndPreview(t *testing.T) { var g service.GeneratorSettings admin.mustStatus(200, "GET", "/api/v1/settings/generator", nil).json(t, &g) - if g.DNS.DefaultTTL != 3600 || g.DHCP.ValidLifetime != 3600 || len(g.DHCP.Interfaces) != 1 { + if g.DNS.DefaultTTL != 3600 || g.DHCP.ValidLifetime != 3600 || len(g.DHCP.Interfaces) != 1 || + g.DNS.ZoneDir == "" || g.DHCP.ControlSocket == "" || g.DHCP.LeaseFile == "" || g.DHCP.HooksDir != "" { t.Fatalf("defaults %+v", g) } @@ -57,12 +58,15 @@ func TestGeneratorSettingsAndPreview(t *testing.T) { g.DNS.PrimaryNS = "ns1.office.test" g.DNS.Hostmaster = "bad@mail@box" + g.DHCP.LeaseFile = "/var/lib/kea/../leases.csv" g.DNS.NameServers = []service.GeneratorNS{{Name: "ns1.office.test", Addresses: []string{"192.0.2.53"}}} r := admin.mustStatus(422, "PUT", "/api/v1/settings/generator", g) - if p := r.problem(t).Problems; len(p) == 0 || p[0].Field != "dns.hostmaster" { - t.Errorf("hostmaster problems %+v", p) + if p := r.problem(t).Problems; len(p) != 2 || p[0].Field != "dns.hostmaster" || p[1].Field != "dhcp.lease_file" { + t.Errorf("problems %+v", p) } g.DNS.Hostmaster = "hostmaster@office.test" + g.DHCP.LeaseFile = "/var/lib/kea/leases.csv" + g.DNS.ZoneDir = "/srv/zones" viewer.mustStatus(403, "PUT", "/api/v1/settings/generator", g) admin.mustStatus(200, "PUT", "/api/v1/settings/generator", g).json(t, &g) if g.DNS.Hostmaster != "hostmaster.office.test" { @@ -80,7 +84,7 @@ func TestGeneratorSettingsAndPreview(t *testing.T) { if conf == nil || zone == nil || rev == nil { t.Fatalf("missing files: %+v", p.DNS) } - if !strings.Contains(conf.Content, "office.test") || !strings.Contains(zone.Content, "192.0.2.10") || + if !strings.Contains(conf.Content, "/srv/zones/db.office.test") || !strings.Contains(p.DHCP[0].Content, "/var/lib/kea/leases.csv") || !strings.Contains(zone.Content, "192.0.2.10") || !strings.Contains(zone.Content, "printer") || !strings.Contains(rev.Content, "www.office.test.") { t.Errorf("unexpected DNS output:\n%s\n%s\n%s", conf.Content, zone.Content, rev.Content) } diff --git a/server/internal/service/configpreview.go b/server/internal/service/configpreview.go index ae39f7a..a338b18 100644 --- a/server/internal/service/configpreview.go +++ b/server/internal/service/configpreview.go @@ -13,16 +13,9 @@ import ( "git.beugsiud.de/beug.k/DDI/server/internal/ipam" ) -// Host paths used for the preview. Agents supply their own paths from M3 on -// (they differ between distributions). -const ( - previewZoneDir = "/etc/bind/ddi-suite/zones" - previewControlSocket = "/run/kea/kea4-ctrl-socket" - previewLeaseFile = "/var/lib/kea/kea-leases4.csv" - // previewSerial stands in for the revision number until revisions - // exist (M3/M4). - previewSerial = 1 -) +// previewSerial is the SOA serial shown in the preview; revisions use their +// own number. +const previewSerial = 1 // GeneratedFile is one generated file. type GeneratedFile struct { @@ -46,8 +39,9 @@ type ConfigPreview struct { DHCPError *GeneratorProblem `json:"dhcp_error,omitempty"` } -// loadSource reads everything the generator needs. -func (s *Service) loadSource(ctx context.Context) (configgen.Source, error) { +// loadSource reads everything the generator needs. serial becomes the SOA +// serial of every zone. +func (s *Service) loadSource(ctx context.Context, serial uint32) (configgen.Source, error) { tx := s.st.Q() nets, err := tx.ListNetworks(ctx) if err != nil { @@ -66,13 +60,14 @@ func (s *Service) loadSource(ctx context.Context) (configgen.Source, error) { return configgen.Source{}, err } src := configgen.Source{ - Serial: previewSerial, + Serial: serial, Addresses: addrs, SubnetIDs: ids, - DNS: settings.dnsSettings(previewZoneDir), + DNS: settings.dnsSettings(), DHCP: configgen.DHCPSettings{ Interfaces: settings.DHCP.Interfaces, DirectClients: settings.DHCP.DirectClients, - ControlSocket: previewControlSocket, LeaseFile: previewLeaseFile, ValidLifetime: settings.DHCP.ValidLifetime, + ControlSocket: settings.DHCP.ControlSocket, LeaseFile: settings.DHCP.LeaseFile, + HooksDir: settings.DHCP.HooksDir, ValidLifetime: settings.DHCP.ValidLifetime, }, } src.Networks = make([]ipam.Network, len(nets)) @@ -83,7 +78,7 @@ func (s *Service) loadSource(ctx context.Context) (configgen.Source, error) { } func files(arts []configgen.Artifact) []GeneratedFile { - out := make([]GeneratedFile, len(arts)) + out := make([]GeneratedFile, len(arts)) // never nil: the API returns [] for i, a := range arts { out[i] = GeneratedFile{Path: a.Path, Content: string(a.Content), CreateOnly: a.CreateOnly} } @@ -101,11 +96,35 @@ func generatorProblem(err error) *GeneratorProblem { // PreviewConfig generates the BIND and Kea configuration for the current // data without storing or deploying anything. func (s *Service) PreviewConfig(ctx context.Context) (*ConfigPreview, error) { - src, err := s.loadSource(ctx) + g, err := s.generate(ctx, previewSerial) if err != nil { return nil, err } - out := &ConfigPreview{DNS: []GeneratedFile{}, DHCP: []GeneratedFile{}, Warnings: []configgen.Warning{}} + out := &ConfigPreview{ + DNS: files(g.DNS), DHCP: files(g.DHCP), Warnings: g.Warnings, + DNSError: g.DNSError, DHCPError: g.DHCPError, + } + return out, nil +} + +// Generated is the output of one generator run. A part that could not be +// generated has no artifacts and a problem instead. +type Generated struct { + DNS []configgen.Artifact + DHCP []configgen.Artifact + Warnings []configgen.Warning + DNSError *GeneratorProblem + DHCPError *GeneratorProblem +} + +// generate runs the generator on the current data (read outside a +// transaction; callers that need a consistent snapshot hold their own). +func (s *Service) generate(ctx context.Context, serial uint32) (*Generated, error) { + src, err := s.loadSource(ctx, serial) + if err != nil { + return nil, err + } + out := &Generated{Warnings: []configgen.Warning{}} if src.DNS.SOA.PrimaryNS == "" || src.DNS.SOA.Hostmaster == "" || len(src.DNS.NameServers) == 0 { out.DNSError = &GeneratorProblem{Code: "dns_settings_missing"} @@ -115,7 +134,7 @@ func (s *Service) PreviewConfig(ctx context.Context) (*ConfigPreview, error) { if arts, err := bindgen.Render(dnsCfg); err != nil { out.DNSError = generatorProblem(err) } else { - out.DNS = files(arts) + out.DNS = arts } } @@ -124,7 +143,7 @@ func (s *Service) PreviewConfig(ctx context.Context) (*ConfigPreview, error) { if arts, err := keagen.Render(dhcpCfg); err != nil { out.DHCPError = generatorProblem(err) } else { - out.DHCP = files(arts) + out.DHCP = arts } return out, nil } diff --git a/server/internal/service/generator.go b/server/internal/service/generator.go index c3079ed..d17d6e4 100644 --- a/server/internal/service/generator.go +++ b/server/internal/service/generator.go @@ -6,6 +6,7 @@ import ( "context" "errors" "net/netip" + "path" "regexp" "strings" @@ -38,6 +39,9 @@ type GeneratorDNS struct { Recursion bool `json:"recursion"` AllowRecursion []string `json:"allow_recursion"` Forwarders []string `json:"forwarders"` + // ZoneDir is the directory on the DNS hosts that holds the zone files + // (must match the DNS agent configuration). + ZoneDir string `json:"zone_dir"` } // GeneratorNS is a name server with optional glue addresses. @@ -51,6 +55,12 @@ type GeneratorDHCP struct { Interfaces []string `json:"interfaces"` DirectClients bool `json:"direct_clients"` ValidLifetime uint32 `json:"valid_lifetime"` + // Paths on the DHCP hosts. Kea 3.x only accepts the control socket in + // its socket directory and the lease file in its data directory. + ControlSocket string `json:"control_socket"` + LeaseFile string `json:"lease_file"` + // HooksDir is empty to load hooks from the directory Kea was built with. + HooksDir string `json:"hooks_dir"` } // DefaultGeneratorSettings are used until an administrator saves settings. @@ -58,9 +68,12 @@ func DefaultGeneratorSettings() GeneratorSettings { return GeneratorSettings{ DNS: GeneratorDNS{ NameServers: []GeneratorNS{}, DefaultTTL: 3600, Refresh: 3600, Retry: 600, Expire: 1209600, Minimum: 300, - AllowRecursion: []string{}, Forwarders: []string{}, + AllowRecursion: []string{}, Forwarders: []string{}, ZoneDir: "/var/lib/bind/ddi-suite", + }, + DHCP: GeneratorDHCP{ + Interfaces: []string{"*"}, DirectClients: true, ValidLifetime: 3600, + ControlSocket: "/run/kea/kea4-ctrl-socket", LeaseFile: "/var/lib/kea/kea-leases4.csv", }, - DHCP: GeneratorDHCP{Interfaces: []string{"*"}, DirectClients: true, ValidLifetime: 3600}, } } @@ -76,6 +89,15 @@ func (s *Service) GetGeneratorSettings(ctx context.Context) (GeneratorSettings, var interfaceName = regexp.MustCompile(`^[A-Za-z0-9._:@*-]{1,64}$`) +// hostPath matches the absolute paths the generators accept. +var hostPath = regexp.MustCompile(`^(/[A-Za-z0-9._+@-]+)+$`) + +// validHostPath reports whether p is a clean absolute path the BIND and Kea +// generators can render (socket paths are limited by sun_path). +func validHostPath(p string, maxLen int) bool { + return len(p) <= maxLen && hostPath.MatchString(p) && path.Clean(p) == p +} + // normalizeGenerator validates settings in place. func normalizeGenerator(g *GeneratorSettings) *Error { var problems []Problem @@ -151,6 +173,18 @@ func normalizeGenerator(g *GeneratorSettings) *Error { if g.DHCP.ValidLifetime < 60 || g.DHCP.ValidLifetime > 31536000 { add("lease_time_range", "dhcp.valid_lifetime") } + if !validHostPath(d.ZoneDir, 255) { + add("invalid_path", "dns.zone_dir") + } + if !validHostPath(g.DHCP.ControlSocket, 100) { + add("invalid_path", "dhcp.control_socket") + } + if !validHostPath(g.DHCP.LeaseFile, 255) { + add("invalid_path", "dhcp.lease_file") + } + if g.DHCP.HooksDir != "" && !validHostPath(g.DHCP.HooksDir, 255) { + add("invalid_path", "dhcp.hooks_dir") + } if d.NameServers == nil { d.NameServers = []GeneratorNS{} } @@ -192,10 +226,10 @@ func (s *Service) SetGeneratorSettings(ctx context.Context, a *Actor, g Generato // dnsSettings converts the settings into generator input (parsing is safe: // values were validated when they were stored). -func (g GeneratorSettings) dnsSettings(zoneDir string) configgen.DNSSettings { +func (g GeneratorSettings) dnsSettings() configgen.DNSSettings { d := g.DNS out := configgen.DNSSettings{ - ZoneDir: zoneDir, + ZoneDir: d.ZoneDir, DefaultTTL: d.DefaultTTL, SOA: configgen.SOA{PrimaryNS: d.PrimaryNS, Hostmaster: d.Hostmaster, Refresh: d.Refresh, Retry: d.Retry, Expire: d.Expire, Minimum: d.Minimum}, diff --git a/web/src/api/schema.d.ts b/web/src/api/schema.d.ts index 8b12063..f023b32 100644 --- a/web/src/api/schema.d.ts +++ b/web/src/api/schema.d.ts @@ -365,6 +365,172 @@ export interface paths { patch?: never; trace?: never; }; + "/agents": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listAgents"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/agents/{id}": { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + get: operations["getAgent"]; + put?: never; + post?: never; + /** @description Revokes the agent's certificate (if not yet revoked) and removes it with its deployments and leases. */ + delete: operations["deleteAgent"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/agents/{id}/revoke": { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + get?: never; + put?: never; + /** + * @description Revokes the agent's certificate. An open connection is closed + * immediately; the agent needs a new enrollment to connect again. + */ + post: operations["revokeAgent"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/enrollment-tokens": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listEnrollmentTokens"]; + put?: never; + post: operations["createEnrollmentToken"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/enrollment-tokens/{id}": { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete: operations["deleteEnrollmentToken"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/revisions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listRevisions"]; + put?: never; + /** + * @description Generates the configuration from the current data and stores it as a + * new, immutable revision. Agents in auto_pull mode are notified and + * apply it. If the generated files equal the latest revision, no new + * revision is created and the latest one is returned with 200. If DNS + * or DHCP cannot be generated, nothing is stored (422, code + * dns_settings_missing, config_invalid or config_failed; params as in + * GeneratorProblem plus "part": "dns" or "dhcp"). + */ + post: operations["createRevision"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/revisions/{number}": { + parameters: { + query?: never; + header?: never; + path: { + number: number; + }; + cookie?: never; + }; + get: operations["getRevision"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/deployments": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listDeployments"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/leases": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listLeases"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/networks": { parameters: { query?: never; @@ -841,11 +1007,19 @@ export interface components { recursion: boolean; allow_recursion: string[]; forwarders: string[]; + /** @description Zone file directory on the DNS hosts (must match the DNS agent configuration). */ + zone_dir: string; }; dhcp: { interfaces: string[]; direct_clients: boolean; valid_lifetime: number; + /** @description Kea control socket path on the DHCP hosts. */ + control_socket: string; + /** @description Kea memfile lease file on the DHCP hosts. */ + lease_file: string; + /** @description Directory of the Kea hook libraries; empty for the directory Kea was built with. */ + hooks_dir: string; }; }; GeneratedFile: { @@ -866,6 +1040,135 @@ export interface components { dns_error?: components["schemas"]["GeneratorProblem"]; dhcp_error?: components["schemas"]["GeneratorProblem"]; }; + /** @enum {string} */ + AgentRole: "dns" | "dhcp"; + /** @enum {string} */ + AgentMode: "auto_pull" | "manual"; + /** @enum {string} */ + HealthState: "unknown" | "ok" | "degraded" | "failed"; + AgentHealth: { + state: components["schemas"]["HealthState"]; + message: string; + /** Format: date-time */ + checked_at?: string; + checks: { + name: string; + state: components["schemas"]["HealthState"]; + message: string; + }[]; + }; + Agent: { + id: string; + role: components["schemas"]["AgentRole"]; + hostname: string; + agent_version: string; + /** @description Version of BIND or Kea as reported by the agent. */ + service_version: string; + /** @description Control stream currently open. */ + connected: boolean; + /** @description Address of the last connection. */ + remote_address?: string; + mode: components["schemas"]["AgentMode"]; + /** Format: date-time */ + enrolled_at: string; + /** Format: date-time */ + last_seen_at?: string; + /** Format: date-time */ + cert_expires_at: string; + /** @description Active revision on the agent, 0 = none. */ + applied_revision: number; + last_good_revision: number; + /** @description Revision the agent should run, 0 = none. */ + desired_revision: number; + health: components["schemas"]["AgentHealth"]; + /** Format: date-time */ + revoked_at?: string; + last_deployment?: components["schemas"]["Deployment"]; + }; + EnrollmentToken: { + id: string; + role: components["schemas"]["AgentRole"]; + description: string; + /** Format: date-time */ + created_at: string; + created_by_name: string; + /** Format: date-time */ + expires_at: string; + /** Format: date-time */ + used_at?: string; + /** @description Agent enrolled with this token. */ + agent_id?: string; + }; + EnrollmentTokenCreated: { + token: components["schemas"]["EnrollmentToken"]; + /** @description Complete one-time token ".". Shown only once. */ + secret: string; + /** @description sha256: of the agent CA certificate (DER). */ + ca_fingerprint: string; + /** @description host:port of the agent API. */ + server_address: string; + /** @description Ready-to-run enrollment command for the agent host. */ + command: string; + }; + RevisionSummary: { + number: number; + /** Format: date-time */ + created_at: string; + created_by_name: string; + comment: string; + dns_files: number; + dhcp_files: number; + warnings: number; + }; + Revision: { + number: number; + /** Format: date-time */ + created_at: string; + created_by_name: string; + comment: string; + dns: components["schemas"]["GeneratedFile"][]; + dhcp: components["schemas"]["GeneratedFile"][]; + warnings: components["schemas"]["GeneratorProblem"][]; + }; + Deployment: { + id: number; + agent_id: string; + agent_hostname: string; + revision: number; + /** @enum {string} */ + trigger: "pull" | "push"; + /** @enum {string} */ + outcome: "succeeded" | "rejected" | "rolled_back" | "failed"; + /** @description Details from the agent (English), e.g. checker output. */ + message: string; + active_revision: number; + /** Format: date-time */ + started_at: string; + /** Format: date-time */ + finished_at: string; + }; + /** @enum {string} */ + LeaseState: "active" | "expired" | "released" | "declined"; + Lease: { + ip: string; + mac: string; + hostname: string; + state: components["schemas"]["LeaseState"]; + /** Format: date-time */ + expires_at?: string; + /** + * Format: date-time + * @description Last client transaction (Kea cltt). + */ + updated_at?: string; + subnet_id: number; + /** @description Network whose Kea subnet ID matches. */ + network_id?: string; + client_id?: string; + agent_id: string; + /** Format: date-time */ + reported_at: string; + }; SavedFilter: { id: string; name: string; @@ -1621,6 +1924,318 @@ export interface operations { default: components["responses"]["Problem"]; }; }; + listAgents: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description All enrolled agents (including revoked ones), sorted by role and hostname. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + items: components["schemas"]["Agent"][]; + }; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + getAgent: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The agent. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Agent"]; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + deleteAgent: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Deleted. */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + default: components["responses"]["Problem"]; + }; + }; + revokeAgent: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The revoked agent. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Agent"]; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + listEnrollmentTokens: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Tokens that are neither used nor expired for more than 7 days, newest first. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + items: components["schemas"]["EnrollmentToken"][]; + }; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + createEnrollmentToken: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + role: components["schemas"]["AgentRole"]; + description?: string; + /** @description Default 24. */ + valid_hours?: number; + }; + }; + }; + responses: { + /** @description Created. The secret is returned only once. */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EnrollmentTokenCreated"]; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + deleteEnrollmentToken: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Deleted. */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + default: components["responses"]["Problem"]; + }; + }; + listRevisions: { + parameters: { + query?: { + limit?: number; + /** @description Return revisions with a lower number. */ + before?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Revisions, newest first. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + items: components["schemas"]["RevisionSummary"][]; + next_before: number | null; + }; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + createRevision: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + comment?: string; + }; + }; + }; + responses: { + /** @description Nothing changed; the latest revision. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Revision"]; + }; + }; + /** @description Created. */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Revision"]; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + getRevision: { + parameters: { + query?: never; + header?: never; + path: { + number: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The revision with all files. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Revision"]; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + listDeployments: { + parameters: { + query?: { + agent_id?: string; + revision?: number; + limit?: number; + before_id?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Deployment results reported by agents, newest first. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + items: components["schemas"]["Deployment"][]; + next_before_id: number | null; + }; + }; + }; + default: components["responses"]["Problem"]; + }; + }; + listLeases: { + parameters: { + query?: { + network_id?: string; + state?: components["schemas"]["LeaseState"]; + /** @description Substring of IP */ + q?: string; + limit?: number; + /** @description Return leases with a higher IP (cursor). */ + after?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Leases reported by the DHCP agents, sorted by IP. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + items: components["schemas"]["Lease"][]; + next_after: string | null; + }; + }; + }; + default: components["responses"]["Problem"]; + }; + }; listNetworks: { parameters: { query?: never; diff --git a/web/src/locales/de.json b/web/src/locales/de.json index b6ce4c1..7104def 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -451,7 +451,8 @@ "constraint_violation": "Die Eingabe verletzt eine Regel der Datenbank.", "invalid_hostmaster": "Ungültiger Hostmaster (z. B. hostmaster@example.com).", "invalid_ttl": "Wert zwischen 1 und 2147483647.", - "invalid_interface": "Ungültiger Schnittstellenname." + "invalid_interface": "Ungültiger Schnittstellenname.", + "invalid_path": "Ungültiger Pfad (absolut, ohne „..“, nur A–Z, a–z, 0–9 und . _ + @ -)." }, "config": { "title": "Erzeugte Konfiguration", @@ -498,6 +499,13 @@ "interfacesHint": "„*“ für alle Schnittstellen.", "directClients": "Clients direkt im Netz bedienen (Raw-Sockets); sonst nur über Relays", "validLifetime": "Standard-Lease-Zeit (Sekunden)", + "paths": "Pfade auf den verwalteten Servern", + "pathsHint": "Müssen zur Konfiguration der Agents und zur Installation von BIND/Kea passen. Der Installer setzt passende Standardwerte.", + "zoneDir": "Zonenverzeichnis (BIND)", + "controlSocket": "Control-Socket (Kea)", + "leaseFile": "Lease-Datei (Kea)", + "hooksDir": "Hook-Verzeichnis (Kea)", + "hooksDirHint": "Leer lassen für das einkompilierte Verzeichnis (Pflicht ab Kea 3.0).", "invalidLine": "Ungültige Zeile: {{line}}" } } diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 3762598..52db92c 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -451,7 +451,8 @@ "constraint_violation": "The input violates a database rule.", "invalid_hostmaster": "Invalid hostmaster (e.g. hostmaster@example.com).", "invalid_ttl": "A value between 1 and 2147483647.", - "invalid_interface": "Invalid interface name." + "invalid_interface": "Invalid interface name.", + "invalid_path": "Invalid path (absolute, no \"..\", only A-Z, a-z, 0-9 and . _ + @ -)." }, "config": { "title": "Generated configuration", @@ -498,6 +499,13 @@ "interfacesHint": "\"*\" for all interfaces.", "directClients": "Serve clients on directly attached networks (raw sockets); otherwise relays only", "validLifetime": "Default lease time (seconds)", + "paths": "Paths on the managed servers", + "pathsHint": "Must match the agent configuration and the BIND/Kea installation. The installer sets matching defaults.", + "zoneDir": "Zone directory (BIND)", + "controlSocket": "Control socket (Kea)", + "leaseFile": "Lease file (Kea)", + "hooksDir": "Hooks directory (Kea)", + "hooksDirHint": "Leave empty for the built-in directory (required from Kea 3.0).", "invalidLine": "Invalid line: {{line}}" } } diff --git a/web/src/pages/GeneratorSettingsPage.tsx b/web/src/pages/GeneratorSettingsPage.tsx index cb9eb3d..b3b834d 100644 --- a/web/src/pages/GeneratorSettingsPage.tsx +++ b/web/src/pages/GeneratorSettingsPage.tsx @@ -239,6 +239,65 @@ function GeneratorForm({ initial }: { initial: Settings }) { +
+

{t('generator.paths')}

+

{t('generator.pathsHint')}

+
+ + {(id) => ( + { + setDns({ ...dns, zone_dir: e.target.value }); + }} + /> + )} + + + {(id) => ( + { + setDhcp({ ...dhcp, control_socket: e.target.value }); + }} + /> + )} + + + {(id) => ( + { + setDhcp({ ...dhcp, lease_file: e.target.value }); + }} + /> + )} + + + {(id) => ( + { + setDhcp({ ...dhcp, hooks_dir: e.target.value }); + }} + /> + )} + +
+
+ `dns.${k}`), 'dns.allow_recursion', 'dns.forwarders', + 'dns.zone_dir', 'dhcp.interfaces', 'dhcp.valid_lifetime', + 'dhcp.control_socket', + 'dhcp.lease_file', + 'dhcp.hooks_dir', ]} /> {save.isSuccess && {t('common.saved')}}