feat: wire BIND and Kea roles into the agents, allow revoking agents in read-only mode
ci / Protobuf (push) Successful in 2m18s
ci / Go (lint, test, vuln) (push) Failing after 3m11s
ci / Shell and licence headers (push) Successful in 8s
ci / Web (lint, test, audit, build) (push) Successful in 1m14s
ci / Integration (real BIND and Kea) (push) Successful in 1m42s
ci / Release build (dry run) (push) Failing after 2m24s
ci / Development environment (compose) (push) Successful in 38s

Revoking or deleting an agent and deleting enrollment tokens lock agents
out, a security function that must stay available when the license is
read-only. Translations for the new problem codes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
beug.k@beugsiud.de
2026-10-13 19:19:31 +02:00
co-authored by Claude Opus 5.5
parent 31fb8285eb
commit 56892631be
7 changed files with 28 additions and 31 deletions
+2 -7
View File
@@ -4,18 +4,13 @@
package main
import (
"errors"
"os"
"git.beugsiud.de/beug.k/DDI/agent/core"
"git.beugsiud.de/beug.k/DDI/agent/role/dhcp"
"git.beugsiud.de/beug.k/DDI/internal/brand"
)
func main() {
os.Exit(core.Main(brand.AgentDHCPBinary, core.KindDHCP, newRole, os.Args[1:], os.Stdout, os.Stderr))
}
// newRole is a placeholder until the DHCP role package is wired in.
func newRole(func(v any) error) (core.Role, error) {
return nil, errors.New("DHCP role not available in this build")
os.Exit(core.Main(brand.AgentDHCPBinary, core.KindDHCP, dhcp.New, os.Args[1:], os.Stdout, os.Stderr))
}
+2 -7
View File
@@ -4,18 +4,13 @@
package main
import (
"errors"
"os"
"git.beugsiud.de/beug.k/DDI/agent/core"
"git.beugsiud.de/beug.k/DDI/agent/role/dns"
"git.beugsiud.de/beug.k/DDI/internal/brand"
)
func main() {
os.Exit(core.Main(brand.AgentDNSBinary, core.KindDNS, newRole, os.Args[1:], os.Stdout, os.Stderr))
}
// newRole is a placeholder until the DNS role package is wired in.
func newRole(func(v any) error) (core.Role, error) {
return nil, errors.New("DNS role not available in this build")
os.Exit(core.Main(brand.AgentDNSBinary, core.KindDNS, dns.New, os.Args[1:], os.Stdout, os.Stderr))
}
+6
View File
@@ -324,6 +324,12 @@ func TestRevisionsAndDeployments(t *testing.T) {
if err := e.svc.AgentHeartbeat(ctx, dnsAgent, service.HeartbeatInput{AppliedRevision: 2, LastGoodRevision: 2}); err != nil {
t.Errorf("heartbeat in read-only mode: %v", err)
}
// Locking an agent out stays possible.
ag = service.AgentDTO{}
admin.mustStatus(200, "POST", "/api/v1/agents/"+dnsAgent+"/revoke", nil).json(t, &ag)
if ag.RevokedAt == nil {
t.Errorf("not revoked in read-only mode: %+v", ag)
}
}
func TestLeases(t *testing.T) {
+5 -3
View File
@@ -62,11 +62,13 @@ func (a *API) Routes() []Route {
// Agents and enrollment (ADR 0021).
perm(get, "/api/v1/agents", auth.PermIPAMRead, false, a.listAgents),
perm(get, "/api/v1/agents/{id}", auth.PermIPAMRead, false, a.getAgent),
perm(del, "/api/v1/agents/{id}", auth.PermSettingsAdm, true, a.deleteAgent),
perm(post, "/api/v1/agents/{id}/revoke", auth.PermSettingsAdm, true, a.revokeAgent),
// Locking agents out is a security function and stays available in
// license read-only mode, like deleting enrollment tokens.
perm(del, "/api/v1/agents/{id}", auth.PermSettingsAdm, false, a.deleteAgent),
perm(post, "/api/v1/agents/{id}/revoke", auth.PermSettingsAdm, false, a.revokeAgent),
perm(get, "/api/v1/enrollment-tokens", auth.PermSettingsAdm, false, a.listEnrollmentTokens),
perm(post, "/api/v1/enrollment-tokens", auth.PermSettingsAdm, true, a.createEnrollmentToken),
perm(del, "/api/v1/enrollment-tokens/{id}", auth.PermSettingsAdm, true, a.deleteEnrollmentToken),
perm(del, "/api/v1/enrollment-tokens/{id}", auth.PermSettingsAdm, false, a.deleteEnrollmentToken),
// Revisions, deployments and leases.
perm(get, "/api/v1/revisions", auth.PermIPAMRead, false, a.listRevisions),
+3 -10
View File
@@ -199,11 +199,10 @@ func (s *Service) loadAgentDTO(ctx context.Context, tx *store.Tx, id string) (Ag
return s.agentDTO(a, desired, last), nil
}
// RevokeAgent revokes the agent's certificate and closes its stream.
// RevokeAgent revokes the agent's certificate and closes its stream. Like
// DeleteAgent and DeleteEnrollmentToken it works in license read-only mode:
// locking out an agent is a security function.
func (s *Service) RevokeAgent(ctx context.Context, a *Actor, id string) (AgentDTO, error) {
if err := s.requireWritable(); err != nil {
return AgentDTO{}, err
}
if !ValidUUID(id) {
return AgentDTO{}, ErrNotFound
}
@@ -237,9 +236,6 @@ func (s *Service) RevokeAgent(ctx context.Context, a *Actor, id string) (AgentDT
// DeleteAgent removes an agent with its deployments and leases. Its
// certificate becomes worthless with the row.
func (s *Service) DeleteAgent(ctx context.Context, a *Actor, id string) error {
if err := s.requireWritable(); err != nil {
return err
}
if !ValidUUID(id) {
return ErrNotFound
}
@@ -412,9 +408,6 @@ func (s *Service) ListEnrollmentTokens(ctx context.Context) ([]EnrollmentTokenDT
// DeleteEnrollmentToken removes a token; an unused token becomes invalid.
func (s *Service) DeleteEnrollmentToken(ctx context.Context, a *Actor, id string) error {
if err := s.requireWritable(); err != nil {
return err
}
if !tokenIDPattern.MatchString(id) {
return ErrNotFound
}
+5 -2
View File
@@ -395,7 +395,9 @@
"filter_name_taken": "Ein Filter mit diesem Namen existiert bereits.",
"dns_settings_missing": "Für DNS fehlen noch Primär-Nameserver, Hostmaster und Nameserver in den DNS/DHCP-Einstellungen.",
"config_invalid": "Die Konfiguration ist ungültig ({{path}}: {{message}}).",
"config_failed": "Die Konfiguration konnte nicht erzeugt werden: {{message}}"
"config_failed": "Die Konfiguration konnte nicht erzeugt werden: {{message}}",
"agent_api_unavailable": "Die Agent-Schnittstelle des Servers ist nicht eingerichtet.",
"revision_limit": "Die höchste mögliche Revisionsnummer ist erreicht."
},
"problems": {
"required": "Pflichtfeld.",
@@ -466,7 +468,8 @@
"invalid_hostmaster": "Ungültiger Hostmaster (z. B. hostmaster@example.com).",
"invalid_ttl": "Wert zwischen 1 und 2147483647.",
"invalid_interface": "Ungültiger Schnittstellenname.",
"invalid_path": "Ungültiger Pfad (absolut, ohne „..“, nur A–Z, a–z, 0–9 und . _ + @ -)."
"invalid_path": "Ungültiger Pfad (absolut, ohne „..“, nur A–Z, a–z, 0–9 und . _ + @ -).",
"out_of_range": "Der Wert liegt außerhalb des erlaubten Bereichs."
},
"config": {
"title": "Erzeugte Konfiguration",
+5 -2
View File
@@ -395,7 +395,9 @@
"filter_name_taken": "A filter with this name already exists.",
"dns_settings_missing": "DNS still needs a primary name server, hostmaster and name servers in the DNS/DHCP settings.",
"config_invalid": "The configuration is invalid ({{path}}: {{message}}).",
"config_failed": "The configuration could not be generated: {{message}}"
"config_failed": "The configuration could not be generated: {{message}}",
"agent_api_unavailable": "The server's agent interface is not configured.",
"revision_limit": "The highest possible revision number has been reached."
},
"problems": {
"required": "Required.",
@@ -466,7 +468,8 @@
"invalid_hostmaster": "Invalid hostmaster (e.g. hostmaster@example.com).",
"invalid_ttl": "A value between 1 and 2147483647.",
"invalid_interface": "Invalid interface name.",
"invalid_path": "Invalid path (absolute, no \"..\", only A-Z, a-z, 0-9 and . _ + @ -)."
"invalid_path": "Invalid path (absolute, no \"..\", only A-Z, a-z, 0-9 and . _ + @ -).",
"out_of_range": "The value is out of the allowed range."
},
"config": {
"title": "Generated configuration",