From e843502cbf402c9c3a538b865fbb9a4ff13221c1 Mon Sep 17 00:00:00 2001 From: "beug.k@beugsiud.de" Date: Tue, 13 Oct 2026 14:54:30 +0200 Subject: [PATCH] feat: M3 contracts for agents, revisions and bundles Shared Apache-2.0 packages for the signed bundle format and the agent certificate conventions, the agent role interface, REST API spec for agents, enrollment tokens, revisions, deployments and leases, and ADR 0021. Generator settings now hold the target paths on the managed hosts. Co-Authored-By: Claude Opus 5.5 --- agent/cmd/ddi-agent-dhcp/main.go | 2 +- agent/cmd/ddi-agent-dns/main.go | 2 +- agent/core/cli.go | 13 +- agent/core/cli_test.go | 2 +- agent/core/role.go | 119 +++++ docs/adr/0021-agent-strecke-m3.md | 55 ++ docs/adr/README.md | 1 + internal/agentpki/agentpki.go | 133 +++++ internal/agentpki/agentpki_test.go | 131 +++++ internal/bundle/bundle.go | 211 ++++++++ internal/bundle/bundle_test.go | 172 ++++++ proto/ddi/agent/v1/agent.proto | 5 + proto/gen/ddi/agent/v1/agent.pb.go | 53 +- server/api/openapi.yaml | 371 ++++++++++++- server/internal/httpapi/generator_test.go | 12 +- server/internal/service/configpreview.go | 59 ++- server/internal/service/generator.go | 42 +- web/src/api/schema.d.ts | 615 ++++++++++++++++++++++ web/src/locales/de.json | 10 +- web/src/locales/en.json | 10 +- web/src/pages/GeneratorSettingsPage.tsx | 63 +++ 21 files changed, 2023 insertions(+), 58 deletions(-) create mode 100644 agent/core/role.go create mode 100644 docs/adr/0021-agent-strecke-m3.md create mode 100644 internal/agentpki/agentpki.go create mode 100644 internal/agentpki/agentpki_test.go create mode 100644 internal/bundle/bundle.go create mode 100644 internal/bundle/bundle_test.go 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')}}