Files
DDI/agent/role/dhcp/dhcp_test.go
T
beug.k@beugsiud.deandClaude Opus 5.5 562f35c10c feat(agent/dhcp): Kea DHCPv4 role over the UNIX control socket
Validate requires exactly kea-dhcp4.conf with this agent's control
socket and runs kea-dhcp4 -t. Activate writes the file atomically and
sends config-reload; on failure the previous file is restored and
reloaded. Health uses status-get (pid, uptime, last reload, socket
state) and checks that the installed file is the expected revision.
Leases pages through lease4-get-page and maps Kea lease states
(default, declined, expired-reclaimed, released) to core lease states.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:14:34 +02:00

510 lines
16 KiB
Go

// SPDX-License-Identifier: Apache-2.0
package dhcp
import (
"context"
"encoding/json"
"errors"
"fmt"
"net"
"net/netip"
"os"
"path/filepath"
"runtime"
"slices"
"sort"
"strings"
"sync"
"testing"
"time"
"go.yaml.in/yaml/v3"
"git.beugsiud.de/beug.k/DDI/agent/core"
"git.beugsiud.de/beug.k/DDI/agent/role/internal/command"
"git.beugsiud.de/beug.k/DDI/internal/bundle"
)
type fakeRunner struct {
mu sync.Mutex
calls []string
fn func(name string, args []string) ([]byte, error)
}
func (f *fakeRunner) Run(_ context.Context, name string, args ...string) ([]byte, error) {
f.mu.Lock()
f.calls = append(f.calls, strings.Join(append([]string{name}, args...), " "))
fn := f.fn
f.mu.Unlock()
if fn == nil {
return nil, nil
}
out, err := fn(name, args)
if err != nil {
// Like command.Exec: the error quotes the output.
return out, &command.Error{Name: name, Args: args, Err: err, Output: out}
}
return out, nil
}
// request is a command received by the fake Kea server.
type request struct {
Command string `json:"command"`
Arguments json.RawMessage `json:"arguments"`
}
// fakeKea serves a UNIX control socket like kea-dhcp4: one JSON command per
// connection, one JSON response, then the connection is closed.
type fakeKea struct {
path string
mu sync.Mutex
handler func(req request) any
got []request
}
func startFakeKea(t *testing.T, dir string) *fakeKea {
t.Helper()
f := &fakeKea{path: filepath.Join(dir, "kea4.sock")}
ln, err := net.Listen("unix", f.path)
if err != nil {
t.Skipf("UNIX sockets unavailable: %v", err)
}
t.Cleanup(func() { _ = ln.Close() })
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
go func() {
defer func() { _ = c.Close() }()
var req request
if err := json.NewDecoder(c).Decode(&req); err != nil {
return
}
f.mu.Lock()
f.got = append(f.got, req)
h := f.handler
f.mu.Unlock()
var resp any = map[string]any{"result": 2, "text": "'" + req.Command + "' command not supported."}
if h != nil {
resp = h(req)
}
_ = json.NewEncoder(c).Encode(resp)
}()
}
}()
return f
}
func (f *fakeKea) setHandler(h func(req request) any) {
f.mu.Lock()
defer f.mu.Unlock()
f.handler = h
}
func (f *fakeKea) commands() []string {
f.mu.Lock()
defer f.mu.Unlock()
out := make([]string, len(f.got))
for i, r := range f.got {
out[i] = r.Command
}
return out
}
type env struct {
role *Role
run *fakeRunner
kea *fakeKea
conf string
}
func newEnv(t *testing.T) *env {
t.Helper()
if runtime.GOOS == "windows" {
t.Skip("DHCP role tests need Unix paths")
}
dir := t.TempDir()
kea := startFakeKea(t, dir)
cfg := DefaultConfig()
cfg.ConfigFile = filepath.Join(dir, "kea-dhcp4.conf")
cfg.ControlSocket = kea.path
cfg.CommandTimeout = 5 * time.Second
if len(cfg.ControlSocket) > maxSocketPath {
t.Skip("temporary directory too long for a UNIX socket path")
}
run := &fakeRunner{}
r, err := newRole(cfg, run)
if err != nil {
t.Fatal(err)
}
return &env{role: r, run: run, kea: kea, conf: cfg.ConfigFile}
}
func keaConfig(socket string, subnets ...string) string {
return fmt.Sprintf(`{
"Dhcp4": {
"control-socket": {"socket-type": "unix", "socket-name": %q},
"subnet4": [%s]
}
}
`, socket, strings.Join(subnets, ","))
}
func revision(t *testing.T, n uint64, files map[string]string) core.Revision {
t.Helper()
rev := core.Revision{Number: n, Dir: t.TempDir()}
names := make([]string, 0, len(files))
for p := range files {
names = append(names, p)
}
sort.Strings(names)
for _, p := range names {
if err := os.WriteFile(filepath.Join(rev.Dir, p), []byte(files[p]), 0o600); err != nil {
t.Fatal(err)
}
rev.Files = append(rev.Files, bundle.File{Path: p, Mode: 0o640, Size: int64(len(files[p]))})
}
return rev
}
func ok(args any) map[string]any {
m := map[string]any{"result": 0, "text": "ok"}
if args != nil {
m["arguments"] = args
}
return m
}
func TestNewDecodesConfig(t *testing.T) {
src := "config_file: /etc/kea/dhcp4.json\nlease_page_size: 50\ncommand_timeout: 2m\n"
r, err := New(func(v any) error {
dec := yaml.NewDecoder(strings.NewReader(src))
dec.KnownFields(true)
return dec.Decode(v)
})
if err != nil {
t.Fatal(err)
}
cfg := r.(*Role).cfg
if cfg.ConfigFile != "/etc/kea/dhcp4.json" || cfg.LeasePageSize != 50 || cfg.CommandTimeout != 2*time.Minute ||
cfg.ControlSocket != "/run/kea/kea4-ctrl-socket" || cfg.KeaDHCP4 != "kea-dhcp4" {
t.Errorf("config = %+v", cfg)
}
if r.Kind() != core.KindDHCP {
t.Error("wrong kind")
}
for name, src := range map[string]string{
"relative": "config_file: kea.conf",
"page size": "lease_page_size: 0",
"long socket": "control_socket: /" + strings.Repeat("s", 120),
"timeout": "command_timeout: -1s",
"unknown": "socket: /x",
"empty binary": "kea_dhcp4: ' '",
} {
_, err := New(func(v any) error {
dec := yaml.NewDecoder(strings.NewReader(src))
dec.KnownFields(true)
return dec.Decode(v)
})
if err == nil {
t.Errorf("%s: expected an error", name)
}
}
}
func TestServiceVersion(t *testing.T) {
e := newEnv(t)
for out, want := range map[string]string{"3.2.1\n": "Kea 3.2.1", "2.6.5": "Kea 2.6.5", "Kea 3.0.0": "Kea 3.0.0", "usage": ""} {
e.run.fn = func(string, []string) ([]byte, error) { return []byte(out), nil }
got, err := e.role.ServiceVersion(context.Background())
if got != want || (want == "") != (err != nil) {
t.Errorf("%q: got %q, %v", out, got, err)
}
}
}
func TestValidate(t *testing.T) {
e := newEnv(t)
ctx := context.Background()
good := revision(t, 1, map[string]string{FileName: keaConfig(e.kea.path)})
if err := e.role.Validate(ctx, good); err != nil {
t.Fatal(err)
}
if want := "kea-dhcp4 -t " + filepath.Join(good.Dir, FileName); len(e.run.calls) != 1 || e.run.calls[0] != want {
t.Errorf("calls = %v", e.run.calls)
}
list := revision(t, 1, map[string]string{FileName: fmt.Sprintf(
`{"Dhcp4": {"control-sockets": [{"socket-type": "http", "socket-port": 8000}, {"socket-type": "unix", "socket-name": %q}]}}`, e.kea.path)})
if err := e.role.Validate(ctx, list); err != nil {
t.Errorf("control-sockets list: %v", err)
}
cases := map[string]struct {
rev core.Revision
fail bool
want string
}{
"other socket": {rev: revision(t, 1, map[string]string{FileName: keaConfig("/var/run/kea/other")}), want: "dhcp.control_socket"},
"no socket": {rev: revision(t, 1, map[string]string{FileName: `{"Dhcp4": {}}`}), want: "no UNIX control socket"},
"no Dhcp4": {rev: revision(t, 1, map[string]string{FileName: `{"Dhcp6": {}}`}), want: "no Dhcp4"},
"not JSON": {rev: revision(t, 1, map[string]string{FileName: `{"Dhcp4": `}), want: "not valid JSON"},
"two files": {rev: revision(t, 1, map[string]string{FileName: "{}", "extra.conf": "{}"}), want: "exactly"},
"wrong name": {rev: revision(t, 1, map[string]string{"kea.conf": "{}"}), want: "exactly"},
"kea -t rejects": {rev: good, fail: true, want: "configuration check failed"},
}
for name, c := range cases {
t.Run(name, func(t *testing.T) {
e.run.fn = nil
if c.fail {
e.run.fn = func(string, []string) ([]byte, error) {
return []byte("DHCP4_CONFIG_LOAD_FAIL configuration error"), errors.New("exit status 1")
}
}
err := e.role.Validate(ctx, c.rev)
if err == nil || !strings.Contains(err.Error(), c.want) {
t.Fatalf("err = %v, want %q", err, c.want)
}
})
}
}
func TestActivate(t *testing.T) {
e := newEnv(t)
ctx := context.Background()
if err := os.WriteFile(e.conf, []byte("bootstrap"), 0o600); err != nil {
t.Fatal(err)
}
e.kea.setHandler(func(req request) any { return ok(nil) })
rev := revision(t, 1, map[string]string{FileName: keaConfig(e.kea.path)})
if err := e.role.Activate(ctx, rev, nil); err != nil {
t.Fatal(err)
}
got, _ := os.ReadFile(e.conf)
if string(got) != keaConfig(e.kea.path) {
t.Errorf("config = %q", got)
}
if fi, _ := os.Stat(e.conf); fi.Mode().Perm() != 0o640 {
t.Errorf("mode = %o", fi.Mode().Perm())
}
if cmds := e.kea.commands(); !slices.Equal(cmds, []string{"config-reload"}) {
t.Errorf("commands = %v", cmds)
}
}
func TestActivateRestoresOnReloadFailure(t *testing.T) {
e := newEnv(t)
ctx := context.Background()
if err := os.WriteFile(e.conf, []byte("previous"), 0o600); err != nil {
t.Fatal(err)
}
n := 0
var mu sync.Mutex
e.kea.setHandler(func(req request) any {
mu.Lock()
defer mu.Unlock()
n++
if n == 1 {
return map[string]any{"result": 1, "text": "Config reload failed: subnet pool out of range"}
}
return ok(nil)
})
rev := revision(t, 2, map[string]string{FileName: keaConfig(e.kea.path)})
err := e.role.Activate(ctx, rev, nil)
if err == nil || !strings.Contains(err.Error(), "pool out of range") {
t.Fatalf("err = %v", err)
}
got, _ := os.ReadFile(e.conf)
if string(got) != "previous" {
t.Errorf("config not restored: %q", got)
}
if fi, _ := os.Stat(e.conf); fi.Mode().Perm() != 0o600 {
t.Errorf("mode not restored: %o", fi.Mode().Perm())
}
if cmds := e.kea.commands(); !slices.Equal(cmds, []string{"config-reload", "config-reload"}) {
t.Errorf("commands = %v", cmds)
}
}
func TestActivateWithoutKea(t *testing.T) {
e := newEnv(t)
e.role.cfg.ControlSocket = filepath.Join(filepath.Dir(e.kea.path), "missing.sock")
if err := os.WriteFile(e.conf, []byte("previous"), 0o600); err != nil {
t.Fatal(err)
}
rev := revision(t, 1, map[string]string{FileName: keaConfig(e.kea.path)})
if err := e.role.Activate(context.Background(), rev, nil); err == nil || !strings.Contains(err.Error(), "control socket") {
t.Fatalf("err = %v", err)
}
if got, _ := os.ReadFile(e.conf); string(got) != "previous" {
t.Errorf("config not restored: %q", got)
}
}
func TestHealth(t *testing.T) {
e := newEnv(t)
ctx := context.Background()
rev := revision(t, 3, map[string]string{FileName: keaConfig(e.kea.path)})
if err := os.WriteFile(e.conf, []byte(keaConfig(e.kea.path)), 0o640); err != nil {
t.Fatal(err)
}
sockets := map[string]any{"status": "ready"}
e.kea.setHandler(func(req request) any {
if req.Command != "status-get" {
return map[string]any{"result": 2, "text": "unsupported"}
}
// Kea replies with a list when relayed through the Control Agent.
return []any{ok(map[string]any{"pid": 42, "uptime": 3600, "reload": 5, "sockets": sockets,
"multi-threading-enabled": true})}
})
h := e.role.Health(ctx, &rev)
if h.State != core.HealthOK || !strings.Contains(h.Message, "pid 42") || !strings.Contains(h.Message, "configured 5s ago") {
t.Fatalf("health = %+v", h)
}
sockets = map[string]any{"status": "retrying", "errors": []string{"eth1: no such interface"}}
if h := e.role.Health(ctx, &rev); h.State != core.HealthDegraded {
t.Errorf("retrying sockets: %+v", h)
}
sockets = map[string]any{"status": "failed", "errors": []string{"eth1: no such interface"}}
if h := e.role.Health(ctx, &rev); h.State != core.HealthFailed {
t.Errorf("failed sockets: %+v", h)
}
sockets = map[string]any{"status": "ready"}
if err := os.WriteFile(e.conf, []byte("edited by hand"), 0o640); err != nil {
t.Fatal(err)
}
if h := e.role.Health(ctx, &rev); h.State != core.HealthDegraded {
t.Errorf("modified file: %+v", h)
}
if h := e.role.Health(ctx, nil); h.State != core.HealthOK {
t.Errorf("without revision: %+v", h)
}
e.kea.setHandler(func(request) any { return map[string]any{"text": "no result"} })
if h := e.role.Health(ctx, nil); h.State != core.HealthFailed {
t.Errorf("response without result: %+v", h)
}
e.role.cfg.ControlSocket = filepath.Join(filepath.Dir(e.kea.path), "missing.sock")
if h := e.role.Health(ctx, nil); h.State != core.HealthFailed {
t.Errorf("no socket: %+v", h)
}
}
func TestLeasesPaging(t *testing.T) {
e := newEnv(t)
e.role.cfg.LeasePageSize = 2
now := time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)
e.role.now = func() time.Time { return now }
cltt := now.Add(-time.Hour).Unix()
all := []map[string]any{
{"ip-address": "192.0.2.10", "hw-address": "1A:1B:1C:1D:1E:1F", "hostname": "pc1.office.test.", "state": 0,
"cltt": cltt, "valid-lft": 7200, "subnet-id": 1, "client-id": "01:1A:1B:1C:1D:1E:1F", "fqdn-fwd": true},
{"ip-address": "192.0.2.11", "hw-address": "00:11:22:33:44:55", "state": 0, "cltt": cltt, "valid-lft": 1800, "subnet-id": 1},
{"ip-address": "192.0.2.12", "hw-address": "00:11:22:33:44:56", "state": 1, "cltt": cltt, "valid-lft": 86400, "subnet-id": 1},
{"ip-address": "192.0.2.13", "hw-address": "00:11:22:33:44:57", "state": 2, "cltt": cltt, "valid-lft": 60, "subnet-id": 2},
{"ip-address": "192.0.2.14", "hw-address": "", "state": 3, "cltt": cltt, "valid-lft": 4294967295, "subnet-id": 2},
}
var pages []string
var mu sync.Mutex
e.kea.setHandler(func(req request) any {
var args struct {
From string `json:"from"`
Limit int `json:"limit"`
}
_ = json.Unmarshal(req.Arguments, &args)
mu.Lock()
pages = append(pages, args.From)
mu.Unlock()
var page []map[string]any
for _, l := range all {
ip := netip.MustParseAddr(l["ip-address"].(string))
if args.From == "start" || netip.MustParseAddr(args.From).Less(ip) {
page = append(page, l)
}
if len(page) == args.Limit {
break
}
}
if len(page) == 0 {
return map[string]any{"result": 3, "text": "0 IPv4 lease(s) found.", "arguments": map[string]any{"leases": []any{}, "count": 0}}
}
return ok(map[string]any{"leases": page, "count": len(page)})
})
leases, err := e.role.Leases(context.Background())
if err != nil {
t.Fatal(err)
}
if !slices.Equal(pages, []string{"start", "192.0.2.11", "192.0.2.13"}) {
t.Errorf("pages requested from %v", pages)
}
if len(leases) != 5 {
t.Fatalf("got %d leases", len(leases))
}
updated := time.Unix(cltt, 0).UTC()
want0 := core.Lease{IP: netip.MustParseAddr("192.0.2.10"), MAC: "1a:1b:1c:1d:1e:1f", Hostname: "pc1.office.test",
State: core.LeaseActive, Expires: updated.Add(2 * time.Hour), Updated: updated, SubnetID: 1, ClientID: "01:1a:1b:1c:1d:1e:1f"}
if leases[0] != want0 {
t.Errorf("lease 0 = %+v\nwant %+v", leases[0], want0)
}
states := []core.LeaseState{core.LeaseActive, core.LeaseExpired, core.LeaseDeclined, core.LeaseExpired, core.LeaseReleased}
for i, l := range leases {
if l.State != states[i] {
t.Errorf("lease %s: state %d, want %d", l.IP, l.State, states[i])
}
}
if !leases[4].Expires.IsZero() || leases[4].MAC != "" {
t.Errorf("infinite lease = %+v", leases[4])
}
}
func TestLeasesEmptyAndUnsupported(t *testing.T) {
e := newEnv(t)
e.kea.setHandler(func(request) any { return map[string]any{"result": 3, "text": "0 IPv4 lease(s) found."} })
if leases, err := e.role.Leases(context.Background()); err != nil || len(leases) != 0 {
t.Errorf("empty: %v, %v", leases, err)
}
e.kea.setHandler(nil) // result 2: unsupported
if _, err := e.role.Leases(context.Background()); err == nil || !strings.Contains(err.Error(), "lease_cmds") {
t.Errorf("unsupported: %v", err)
}
e.role.cfg.LeasePageSize = 1
e.kea.setHandler(func(request) any {
return ok(map[string]any{"leases": []any{map[string]any{"ip-address": "192.0.2.1", "state": 0}}, "count": 1})
})
if _, err := e.role.Leases(context.Background()); err == nil || !strings.Contains(err.Error(), "did not advance") {
t.Errorf("stuck paging: %v", err)
}
}
func TestNormalizeMAC(t *testing.T) {
for in, want := range map[string]string{
"1A:1B:1C:1D:1E:1F": "1a:1b:1c:1d:1e:1f",
"1a-1b-1c-1d-1e-1f": "1a:1b:1c:1d:1e:1f",
"1a1b.1c1d.1e1f": "1a:1b:1c:1d:1e:1f",
"1A1B1C1D1E1F": "1a:1b:1c:1d:1e:1f",
"1:2:3:a:b:c": "01:02:03:0a:0b:0c",
"": "",
"ZZ:11": "zz:11",
"abc": "abc",
} {
if got := normalizeMAC(in); got != want {
t.Errorf("normalizeMAC(%q) = %q, want %q", in, got, want)
}
}
}
func TestDecodeResponse(t *testing.T) {
if _, err := decodeResponse("x", json.RawMessage(`[]`)); err == nil {
t.Error("empty list accepted")
}
r, err := decodeResponse("x", json.RawMessage(` [{"result": 1, "text": "boom"}]`))
if err != nil || r.Result != 1 || r.Err("x") == nil || !strings.Contains(r.Err("x").Error(), "boom") {
t.Errorf("list: %+v, %v", r, err)
}
}