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>
510 lines
16 KiB
Go
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)
|
|
}
|
|
}
|