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 <noreply@anthropic.com>
This commit is contained in:
beug.k@beugsiud.de
2026-10-13 14:54:30 +02:00
co-authored by Claude Opus 5.5
parent 44de779fbf
commit e843502cbf
21 changed files with 2023 additions and 58 deletions
+1 -1
View File
@@ -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))
}
+1 -1
View File
@@ -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))
}
+2 -11
View File
@@ -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 <command>
Agent role: %s
+1 -1
View File
@@ -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)
}
+119
View File
@@ -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
// <state_dir>/revisions/<Number>/. 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)
+55
View File
@@ -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:<produkt>:agent:<uuid>`, 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>.<secret>`: 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-<rolle>.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 <host:port> --token <token> --ca-hash sha256:<hex> [--server-name <name>]`, `run`, `status`, `version`.
- **Ablauf** (siehe `agent/core/role.go`): Bundle prüfen (`bundle.Verify`, Revision > angewendete) → unter `revisions/<n>/` 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.<zone>`).
- `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.
+1
View File
@@ -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 |
+133
View File
@@ -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:<product>: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:<uuid>".
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:<hex>" 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,
}
}
+131
View File
@@ -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)
}
}
}
+211
View File
@@ -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
}
+172
View File
@@ -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)
}
}
})
}
+5
View File
@@ -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.
+41 -12
View File
@@ -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" +
+369 -2
View File
@@ -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 \"<id>.<secret>\". Shown only once." }
ca_fingerprint: { type: string, description: "sha256:<hex> 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
+8 -4
View File
@@ -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)
}
+39 -20
View File
@@ -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
}
+38 -4
View File
@@ -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},
Generated Vendored
+615
View File
@@ -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 "<id>.<secret>". Shown only once. */
secret: string;
/** @description sha256:<hex> 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;
+9 -1
View File
@@ -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}}"
}
}
+9 -1
View File
@@ -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}}"
}
}
+63
View File
@@ -239,6 +239,65 @@ function GeneratorForm({ initial }: { initial: Settings }) {
</label>
</section>
<section className="card form">
<h2>{t('generator.paths')}</h2>
<p className="muted">{t('generator.pathsHint')}</p>
<div className="grid-2">
<Field label={t('generator.zoneDir')} error={fe['dns.zone_dir']}>
{(id) => (
<input
id={id}
className="mono"
value={dns.zone_dir}
onChange={(e) => {
setDns({ ...dns, zone_dir: e.target.value });
}}
/>
)}
</Field>
<Field label={t('generator.controlSocket')} error={fe['dhcp.control_socket']}>
{(id) => (
<input
id={id}
className="mono"
value={dhcp.control_socket}
onChange={(e) => {
setDhcp({ ...dhcp, control_socket: e.target.value });
}}
/>
)}
</Field>
<Field label={t('generator.leaseFile')} error={fe['dhcp.lease_file']}>
{(id) => (
<input
id={id}
className="mono"
value={dhcp.lease_file}
onChange={(e) => {
setDhcp({ ...dhcp, lease_file: e.target.value });
}}
/>
)}
</Field>
<Field
label={t('generator.hooksDir')}
error={fe['dhcp.hooks_dir']}
hint={t('generator.hooksDirHint')}
>
{(id) => (
<input
id={id}
className="mono"
value={dhcp.hooks_dir}
onChange={(e) => {
setDhcp({ ...dhcp, hooks_dir: e.target.value });
}}
/>
)}
</Field>
</div>
</section>
<ErrorBox
error={save.error}
fields={[
@@ -248,8 +307,12 @@ function GeneratorForm({ initial }: { initial: Settings }) {
...timers.map((k) => `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 && <Notice kind="success">{t('common.saved')}</Notice>}