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>
172 lines
4.9 KiB
Go
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') }
|