Files
DDI/agent/role/dhcp/leases.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

172 lines
4.9 KiB
Go

// SPDX-License-Identifier: Apache-2.0
package dhcp
import (
"context"
"encoding/json"
"fmt"
"net/netip"
"strings"
"time"
"git.beugsiud.de/beug.k/DDI/agent/core"
)
// Kea lease states (Kea source src/lib/dhcpsrv/lease.cc; Kea ARM
// "lease_cmds": state 0 default, 1 declined, 2 expired-reclaimed).
// "released" (3, leases kept for lease affinity) exists since Kea 2.7 /
// 3.0, "registered" (4) only for DHCPv6.
const (
keaStateDefault = 0
keaStateDeclined = 1
keaStateExpiredReclaimed = 2
keaStateReleased = 3
)
// infiniteLifetime is Kea's "infinite" valid lifetime (0xffffffff).
const infiniteLifetime = 0xffffffff
// keaLease is one element of "leases" in a lease4-get-page response
// (Lease4::toElement). Unknown fields are ignored for compatibility with
// newer Kea versions.
type keaLease struct {
IPAddress string `json:"ip-address"`
HWAddress string `json:"hw-address"`
Hostname string `json:"hostname"`
State int64 `json:"state"`
CLTT int64 `json:"cltt"`
ValidLft int64 `json:"valid-lft"`
SubnetID uint32 `json:"subnet-id"`
ClientID string `json:"client-id"`
}
type leasePage struct {
Leases []keaLease `json:"leases"`
Count int `json:"count"`
}
// Leases implements core.LeaseSource. It pages through lease4-get-page
// (lease_cmds hook): the first page starts at "start", every further page
// at the last address of the previous one. The last page is the one with
// fewer leases than the limit; Kea answers result 3 (empty) when nothing
// is left (Kea ARM, "lease4-get-page").
func (r *Role) Leases(ctx context.Context) ([]core.Lease, error) {
var out []core.Lease
from := "start"
var last netip.Addr
for {
resp, err := r.Command(ctx, "lease4-get-page", map[string]any{"from": from, "limit": r.cfg.LeasePageSize})
if err != nil {
return nil, err
}
if resp.Result == ResultEmpty {
return out, nil
}
if resp.Result == ResultUnsupported {
return nil, fmt.Errorf("kea does not support lease4-get-page (is the lease_cmds hook loaded?): %s", resp.Text)
}
if err := resp.Err("lease4-get-page"); err != nil {
return nil, err
}
var page leasePage
if err := json.Unmarshal(resp.Arguments, &page); err != nil {
return nil, fmt.Errorf("kea lease4-get-page: invalid arguments: %w", err)
}
now := r.now()
for _, kl := range page.Leases {
l, err := convertLease(kl, now)
if err != nil {
return nil, err
}
out = append(out, l)
}
if len(page.Leases) == 0 || len(page.Leases) < r.cfg.LeasePageSize {
return out, nil
}
next := out[len(out)-1].IP
// Pages are ordered by address; refuse to loop if Kea does not
// make progress.
if last.IsValid() && !last.Less(next) {
return nil, fmt.Errorf("kea lease4-get-page did not advance beyond %s", last)
}
last = next
from = next.String()
}
}
func convertLease(kl keaLease, now time.Time) (core.Lease, error) {
ip, err := netip.ParseAddr(kl.IPAddress)
if err != nil || !ip.Is4() {
return core.Lease{}, fmt.Errorf("kea returned an invalid lease address %q", kl.IPAddress)
}
l := core.Lease{
IP: ip,
MAC: normalizeMAC(kl.HWAddress),
Hostname: strings.TrimSuffix(kl.Hostname, "."),
SubnetID: kl.SubnetID,
ClientID: strings.ToLower(kl.ClientID),
}
if kl.CLTT > 0 {
l.Updated = time.Unix(kl.CLTT, 0).UTC()
// An infinite lifetime never expires; Expires stays zero.
if kl.ValidLft >= 0 && kl.ValidLft != infiniteLifetime {
l.Expires = l.Updated.Add(time.Duration(kl.ValidLft) * time.Second)
}
}
switch kl.State {
case keaStateDefault:
l.State = core.LeaseActive
if !l.Expires.IsZero() && !l.Expires.After(now) {
// Not reclaimed yet, but expired.
l.State = core.LeaseExpired
}
case keaStateDeclined:
l.State = core.LeaseDeclined
case keaStateExpiredReclaimed:
l.State = core.LeaseExpired
case keaStateReleased:
l.State = core.LeaseReleased
default:
l.State = core.LeaseUnknown
}
return l, nil
}
// normalizeMAC returns a hardware address as lowercase, colon separated
// hex. Kea already prints it that way; other spellings ("-" or "."
// separators, no separators) are converted. Unparsable input is returned
// in lower case.
func normalizeMAC(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
if s == "" {
return s
}
var parts []string
if strings.ContainsAny(s, ":-") {
// Separated single digits ("1:2:3") are padded per byte.
parts = strings.FieldsFunc(s, func(r rune) bool { return r == ':' || r == '-' })
for i, p := range parts {
if len(p) == 1 {
parts[i] = "0" + p
}
}
} else {
hex := strings.ReplaceAll(s, ".", "")
if len(hex)%2 != 0 {
return s
}
for i := 0; i < len(hex); i += 2 {
parts = append(parts, hex[i:i+2])
}
}
for _, p := range parts {
if len(p) != 2 || !isHex(p[0]) || !isHex(p[1]) {
return s
}
}
return strings.Join(parts, ":")
}
func isHex(c byte) bool { return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') }