feat: in-process DHCP client and Manager in vpcd (mulga-990)

Introduces the spinifex/services/vpcd/dhcp package with a Client
interface, typed Lease struct, nclient4-backed implementation, and a
Fake for unit tests. Adds DHCPManager in the vpcd package: subscribes
to vpc.dhcp.acquire / vpc.dhcp.release NATS requests, runs a renewal
goroutine per lease with ±10% jitter on T1/T2, persists leases in the
new spinifex-dhcp-leases KV bucket, and fans out vpc.dhcp.lease-expired
when a lease permanently fails to renew. Wires the Manager into
launchService so it starts on vpcd boot and rehydrates persisted leases
via Bootstrap.

Handler-side wiring (ExternalIPAM → NATS) and the systemd capability
move land separately in mulga-991.

See docs/development/bugs/dhcp-allocate-error.md.
This commit is contained in:
Ben Duncan
2026-04-22 16:45:30 +10:00
parent 889ef361eb
commit f73e151c77
10 changed files with 1721 additions and 3 deletions
+6
View File
@@ -9,6 +9,7 @@ require (
github.com/charmbracelet/lipgloss v1.1.0
github.com/go-chi/chi/v5 v5.2.5
github.com/google/uuid v1.6.0
github.com/insomniacslk/dhcp v0.0.0-20260407060928-11b94ed970f2
github.com/kdomanski/iso9660 v0.4.0
github.com/klauspost/cpuid/v2 v2.3.0
github.com/mulgadc/predastore v1.1.6
@@ -74,6 +75,7 @@ require (
github.com/hashicorp/raft-boltdb/v2 v2.3.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/josharian/native v1.1.0 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/klauspost/reedsolomon v1.13.3 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
@@ -83,6 +85,8 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.21 // indirect
github.com/mdlayher/packet v1.1.2 // indirect
github.com/mdlayher/socket v0.4.1 // indirect
github.com/minio/crc64nvme v1.1.1 // indirect
github.com/minio/highwayhash v1.0.4 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
@@ -92,6 +96,7 @@ require (
github.com/nats-io/jwt/v2 v2.8.1 // indirect
github.com/nats-io/nkeys v0.4.15 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
github.com/pierrec/lz4/v4 v4.1.14 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
@@ -104,6 +109,7 @@ require (
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.etcd.io/bbolt v1.4.3 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
+16 -2
View File
@@ -67,8 +67,6 @@ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.10 h1:GtsxyiF3Nd3JahRBJbxLCCd
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.10/go.mod h1:/j67Z5XBVDx8nZVp9EuFM9/BS5dvBznbqILGuu73hug=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.2 h1:a5UTtD4mHBU3t0o6aHQZFJTNKVfxFWfPX7J0Lr7G+uY=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.2/go.mod h1:6TxbXoDSgBQ225Qd8Q+MbxUxUh6TtNKwbRt/EPS9xso=
github.com/aws/smithy-go v1.23.2 h1:Crv0eatJUQhaManss33hS5r40CG3ZFH+21XSkqMrIUM=
github.com/aws/smithy-go v1.23.2/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
@@ -223,12 +221,19 @@ github.com/hashicorp/raft-boltdb v0.0.0-20230125174641-2a8082862702 h1:RLKEcCuKc
github.com/hashicorp/raft-boltdb v0.0.0-20230125174641-2a8082862702/go.mod h1:nTakvJ4XYq45UXtn0DbwR4aU9ZdjlnIenpbs6Cd+FM0=
github.com/hashicorp/raft-boltdb/v2 v2.3.1 h1:ackhdCNPKblmOhjEU9+4lHSJYFkJd6Jqyvj6eW9pwkc=
github.com/hashicorp/raft-boltdb/v2 v2.3.1/go.mod h1:n4S+g43dXF1tqDT+yzcXHhXM6y7MrlUd3TTwGRcUvQE=
github.com/hugelgupf/socketpair v0.0.0-20190730060125-05d35a94e714 h1:/jC7qQFrv8CrSJVmaolDVOxTfS9kc36uB6H40kdbQq8=
github.com/hugelgupf/socketpair v0.0.0-20190730060125-05d35a94e714/go.mod h1:2Goc3h8EklBH5mspfHFxBnEoURQCGzQQH1ga9Myjvis=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/insomniacslk/dhcp v0.0.0-20260407060928-11b94ed970f2 h1:G3irkWwmpl0vH/nn83K2AHqLUZweC7XAONuwXy/w9Co=
github.com/insomniacslk/dhcp v0.0.0-20260407060928-11b94ed970f2/go.mod h1:qfvBmyDNp+/liLEYWRvqny/PEz9hGe2Dz833eXILSmo=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
github.com/josharian/native v1.0.1-0.20221213033349-c1e37c09b531/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA=
github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
@@ -277,6 +282,10 @@ github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh
github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/mdlayher/packet v1.1.2 h1:3Up1NG6LZrsgDVn6X4L9Ge/iyRyxFEFD9o6Pr3Q1nQY=
github.com/mdlayher/packet v1.1.2/go.mod h1:GEu1+n9sG5VtiRE4SydOmX5GTwyyYlteZiFU+x0kew4=
github.com/mdlayher/socket v0.4.1 h1:eM9y2/jlbs1M615oshPQOHZzj6R6wMT7bX5NPiQvn2U=
github.com/mdlayher/socket v0.4.1/go.mod h1:cAqeGjoufqdxWkD7DkpyS+wcefOtmu5OQ8KuoJGIReA=
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4=
@@ -315,6 +324,8 @@ github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0Mw
github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pierrec/lz4/v4 v4.1.14 h1:+fL8AQEZtz/ijeNnpduH0bROTu0O3NZAlPjQxGn8LwE=
github.com/pierrec/lz4/v4 v4.1.14/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@@ -399,6 +410,8 @@ github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=
github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923 h1:tHNk7XK9GkmKUR6Gh8gVBKXc2MVSZ4G/NnWLtzw4gNA=
github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923/go.mod h1:eLL9Nub3yfAho7qB0MzZizFhTU2QkLeoVsWdHtDW264=
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
@@ -479,6 +492,7 @@ golang.org/x/sys v0.0.0-20211013075003-97ac67df715c/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220622161953-175b2fd9d664/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+88
View File
@@ -0,0 +1,88 @@
// Package dhcp provides an in-process DHCPv4 client used by spinifex-vpcd
// to obtain, renew and release real server-bound leases on behalf of the
// external IPAM pool. It replaces the historical "shell out to sudo dhcpcd"
// path which had no renewal, phantom leases under -T, and a release channel
// that collided with a host-side dhcpcd daemon managing the same bridge.
//
// The real implementation (NewNClient4) opens an AF_PACKET socket per DORA
// on the target bridge via github.com/insomniacslk/dhcp/dhcpv4/nclient4.
// Tests inject Fake instead.
package dhcp
import (
"context"
"net"
"time"
)
// Client is the per-handshake DHCP surface. Backed by NewNClient4 in
// production; Fake satisfies this for unit tests.
type Client interface {
// Acquire performs a full DORA on req.Bridge and returns the
// server-bound lease. Blocks until ACK or ctx cancellation.
Acquire(ctx context.Context, req AcquireRequest) (*Lease, error)
// Renew refreshes an existing lease with its recorded server-id.
// Returns a new Lease reflecting the refreshed timers.
Renew(ctx context.Context, lease *Lease) (*Lease, error)
// Release sends DHCPRELEASE to the server-id in the lease.
Release(ctx context.Context, lease *Lease) error
}
// AcquireRequest identifies a new DHCP lease. Fields map 1:1 to DHCP
// options and the payload chaddr.
type AcquireRequest struct {
Bridge string // OVS bridge carrying the AF_PACKET socket
ClientID string // option 61 — client-identifier
Hostname string // option 12 — host-name
VendorClass string // option 60 — vendor-class-identifier
HWAddr net.HardwareAddr // chaddr in the DHCP payload
}
// Lease is a server-bound DHCPv4 lease. Carries enough state for Renew
// and Release, plus the raw Offer/ACK packets so that the Manager can
// resume after a daemon restart.
type Lease struct {
// Request context — echoed for audit; also used to rebuild the
// underlying nclient4 client on Renew/Release.
Bridge string
ClientID string
Hostname string
VendorClass string
HWAddr net.HardwareAddr
// Wire state extracted from the server's ACK.
IP net.IP
SubnetMask net.IPMask
Routers []net.IP
DNS []net.IP
ServerID net.IP
AcquiredAt time.Time
LeaseDuration time.Duration
T1 time.Duration // renew timer (option 58, or lease/2)
T2 time.Duration // rebind timer (option 59, or lease*7/8)
// Raw wire packets. Opaque to callers; nclient4 needs them to build
// RENEW / RELEASE messages that hit the same server binding. Persisted
// in KV for crash recovery.
RawOffer []byte
RawACK []byte
}
// ExpiresAt returns the absolute deadline at which the upstream server is
// free to reassign this IP.
func (l *Lease) ExpiresAt() time.Time {
return l.AcquiredAt.Add(l.LeaseDuration)
}
// RenewAt returns the absolute time the Manager should attempt a RENEW
// (option 58 / T1).
func (l *Lease) RenewAt() time.Time {
return l.AcquiredAt.Add(l.T1)
}
// RebindAt returns the absolute time the Manager should attempt a REBIND
// (option 59 / T2).
func (l *Lease) RebindAt() time.Time {
return l.AcquiredAt.Add(l.T2)
}
+113
View File
@@ -0,0 +1,113 @@
package dhcp_test
import (
"context"
"errors"
"net"
"testing"
"time"
"github.com/mulgadc/spinifex/spinifex/services/vpcd/dhcp"
)
func TestLeaseTimers(t *testing.T) {
acq := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
l := &dhcp.Lease{
AcquiredAt: acq,
LeaseDuration: time.Hour,
T1: 30 * time.Minute,
T2: 52*time.Minute + 30*time.Second,
}
if got := l.RenewAt(); !got.Equal(acq.Add(30 * time.Minute)) {
t.Errorf("RenewAt: got %v, want %v", got, acq.Add(30*time.Minute))
}
if got := l.RebindAt(); !got.Equal(acq.Add(52*time.Minute + 30*time.Second)) {
t.Errorf("RebindAt: got %v, want %v", got, acq.Add(52*time.Minute+30*time.Second))
}
if got := l.ExpiresAt(); !got.Equal(acq.Add(time.Hour)) {
t.Errorf("ExpiresAt: got %v, want %v", got, acq.Add(time.Hour))
}
}
func TestFakeAcquireReturnsLeaseAndTracksCount(t *testing.T) {
f := dhcp.NewFake()
mac, _ := net.ParseMAC("02:00:00:aa:bb:cc")
lease, err := f.Acquire(context.Background(), dhcp.AcquireRequest{
Bridge: "br-wan", ClientID: "eni-1", HWAddr: mac,
})
if err != nil {
t.Fatalf("Acquire: %v", err)
}
if lease == nil || lease.IP == nil {
t.Fatalf("expected lease with IP, got %+v", lease)
}
if f.AcquireCount() != 1 {
t.Errorf("acquire count = %d, want 1", f.AcquireCount())
}
if held, ok := f.HeldLease("eni-1"); !ok || held.IP.String() != lease.IP.String() {
t.Errorf("held lease = %v/%v, want match", held, ok)
}
}
func TestFakeAcquireHookOverridesDefault(t *testing.T) {
f := dhcp.NewFake()
f.AcquireHook = func(req dhcp.AcquireRequest) (*dhcp.Lease, error) {
return nil, errors.New("injected")
}
_, err := f.Acquire(context.Background(), dhcp.AcquireRequest{Bridge: "br-wan", ClientID: "x"})
if err == nil || err.Error() != "injected" {
t.Fatalf("expected injected error, got %v", err)
}
}
func TestFakeRenewRefreshesAcquiredAt(t *testing.T) {
f := dhcp.NewFake()
lease, err := f.Acquire(context.Background(), dhcp.AcquireRequest{
Bridge: "br-wan", ClientID: "eni-2",
})
if err != nil {
t.Fatalf("Acquire: %v", err)
}
firstAcq := lease.AcquiredAt
time.Sleep(10 * time.Millisecond)
renewed, err := f.Renew(context.Background(), lease)
if err != nil {
t.Fatalf("Renew: %v", err)
}
if !renewed.AcquiredAt.After(firstAcq) {
t.Errorf("renewed AcquiredAt %v not after original %v", renewed.AcquiredAt, firstAcq)
}
if f.RenewCount() != 1 {
t.Errorf("renew count = %d, want 1", f.RenewCount())
}
}
func TestFakeReleaseClearsTrackedLease(t *testing.T) {
f := dhcp.NewFake()
lease, _ := f.Acquire(context.Background(), dhcp.AcquireRequest{
Bridge: "br-wan", ClientID: "eni-3",
})
if err := f.Release(context.Background(), lease); err != nil {
t.Fatalf("Release: %v", err)
}
if _, ok := f.HeldLease("eni-3"); ok {
t.Errorf("expected lease removed after Release")
}
if f.ReleaseCount() != 1 {
t.Errorf("release count = %d, want 1", f.ReleaseCount())
}
}
func TestFakeRenewHookSurfacesError(t *testing.T) {
f := dhcp.NewFake()
f.RenewHook = func(l *dhcp.Lease) (*dhcp.Lease, error) {
return nil, errors.New("server NAK")
}
lease, _ := f.Acquire(context.Background(), dhcp.AcquireRequest{Bridge: "br-wan", ClientID: "eni-4"})
if _, err := f.Renew(context.Background(), lease); err == nil {
t.Fatal("expected hook error")
}
}
+177
View File
@@ -0,0 +1,177 @@
package dhcp
import (
"context"
"fmt"
"net"
"sync"
"time"
)
// Fake is a configurable in-memory Client for unit tests. It mimics the
// DORA / RENEW / RELEASE lifecycle well enough to exercise the Manager
// without opening real sockets. Hooks let tests inject errors or custom
// responses at each step.
type Fake struct {
mu sync.Mutex
// Default template for synthesised leases. Tests can override via
// SetDefaultLease.
defaultLease LeaseTemplate
// Optional hooks — if non-nil the fake calls the hook instead of
// synthesising a response. Useful for fault injection.
AcquireHook func(AcquireRequest) (*Lease, error)
RenewHook func(*Lease) (*Lease, error)
ReleaseHook func(*Lease) error
// Tracked state.
leases map[string]*Lease // keyed by ClientID
acquireCount int
renewCount int
releaseCount int
}
// LeaseTemplate configures the synthetic lease Fake returns from Acquire
// when no AcquireHook is set.
type LeaseTemplate struct {
IP net.IP
SubnetMask net.IPMask
Routers []net.IP
DNS []net.IP
ServerID net.IP
LeaseDuration time.Duration
}
// DefaultLeaseTemplate returns a reasonable template for tests (RFC 5737
// TEST-NET-1, 1 h lease).
func DefaultLeaseTemplate() LeaseTemplate {
return LeaseTemplate{
IP: net.IPv4(192, 0, 2, 100),
SubnetMask: net.CIDRMask(24, 32),
Routers: []net.IP{net.IPv4(192, 0, 2, 1)},
DNS: []net.IP{net.IPv4(192, 0, 2, 1)},
ServerID: net.IPv4(192, 0, 2, 1),
LeaseDuration: time.Hour,
}
}
// NewFake creates a Fake pre-loaded with DefaultLeaseTemplate.
func NewFake() *Fake {
return &Fake{
defaultLease: DefaultLeaseTemplate(),
leases: map[string]*Lease{},
}
}
// SetDefaultLease overrides the template used for subsequent Acquire calls.
func (f *Fake) SetDefaultLease(tmpl LeaseTemplate) {
f.mu.Lock()
defer f.mu.Unlock()
f.defaultLease = tmpl
}
// AcquireCount returns the number of Acquire calls observed.
func (f *Fake) AcquireCount() int { f.mu.Lock(); defer f.mu.Unlock(); return f.acquireCount }
// RenewCount returns the number of Renew calls observed.
func (f *Fake) RenewCount() int { f.mu.Lock(); defer f.mu.Unlock(); return f.renewCount }
// ReleaseCount returns the number of Release calls observed.
func (f *Fake) ReleaseCount() int { f.mu.Lock(); defer f.mu.Unlock(); return f.releaseCount }
// HeldLease returns the last lease Fake synthesised for the given ClientID.
func (f *Fake) HeldLease(clientID string) (*Lease, bool) {
f.mu.Lock()
defer f.mu.Unlock()
l, ok := f.leases[clientID]
return l, ok
}
func (f *Fake) Acquire(_ context.Context, req AcquireRequest) (*Lease, error) {
f.mu.Lock()
f.acquireCount++
hook := f.AcquireHook
tmpl := f.defaultLease
f.mu.Unlock()
if hook != nil {
lease, err := hook(req)
if err == nil && lease != nil {
f.mu.Lock()
f.leases[req.ClientID] = lease
f.mu.Unlock()
}
return lease, err
}
lease := &Lease{
Bridge: req.Bridge,
ClientID: req.ClientID,
Hostname: req.Hostname,
VendorClass: req.VendorClass,
HWAddr: append(net.HardwareAddr(nil), req.HWAddr...),
IP: tmpl.IP,
SubnetMask: tmpl.SubnetMask,
Routers: tmpl.Routers,
DNS: tmpl.DNS,
ServerID: tmpl.ServerID,
AcquiredAt: time.Now(),
LeaseDuration: tmpl.LeaseDuration,
T1: tmpl.LeaseDuration / 2,
T2: tmpl.LeaseDuration * 7 / 8,
// Synthetic raw packets — opaque marker bytes so Renew/Release
// distinguish a fake lease from a nil-bytes one.
RawOffer: []byte("fake-offer-" + req.ClientID),
RawACK: []byte("fake-ack-" + req.ClientID),
}
f.mu.Lock()
f.leases[req.ClientID] = lease
f.mu.Unlock()
return lease, nil
}
func (f *Fake) Renew(_ context.Context, lease *Lease) (*Lease, error) {
if lease == nil {
return nil, fmt.Errorf("fake renew: lease is nil")
}
f.mu.Lock()
f.renewCount++
hook := f.RenewHook
f.mu.Unlock()
if hook != nil {
renewed, err := hook(lease)
if err == nil && renewed != nil {
f.mu.Lock()
f.leases[lease.ClientID] = renewed
f.mu.Unlock()
}
return renewed, err
}
renewed := *lease
renewed.AcquiredAt = time.Now()
f.mu.Lock()
f.leases[lease.ClientID] = &renewed
f.mu.Unlock()
return &renewed, nil
}
func (f *Fake) Release(_ context.Context, lease *Lease) error {
if lease == nil {
return nil
}
f.mu.Lock()
f.releaseCount++
hook := f.ReleaseHook
f.mu.Unlock()
if hook != nil {
return hook(lease)
}
f.mu.Lock()
delete(f.leases, lease.ClientID)
f.mu.Unlock()
return nil
}
+73
View File
@@ -0,0 +1,73 @@
package dhcp
// NATS subjects that spinifex-vpcd's DHCP manager subscribes to and
// publishes on. The daemon-side handlers in handlers/ec2/vpc send
// requests against the request/reply subjects.
const (
// TopicAcquire is the request/reply subject for obtaining a lease.
// Request: AcquireRequestMsg. Reply: AcquireReplyMsg.
TopicAcquire = "vpc.dhcp.acquire"
// TopicRelease is the request/reply subject for releasing a lease.
// Request: ReleaseRequestMsg. Reply: ReleaseReplyMsg.
TopicRelease = "vpc.dhcp.release"
// TopicLeaseExpired is a fan-out event published by vpcd when a
// renewal permanently fails and the lease has passed its expiry.
// Consumers (EIP / instance / VPC reconcilers) decide whether to
// re-request a lease, reschedule, or fail the pending API call.
TopicLeaseExpired = "vpc.dhcp.lease-expired"
)
// KV bucket for persisted lease state. Keyed by ClientID. Separate from
// the external IPAM allocation ledger by design: IPAM records who owns
// which IP, this bucket records when a lease expires and how to renew it.
const (
KVBucketDHCPLeases = "spinifex-dhcp-leases"
KVBucketDHCPLeasesVersion = 1
)
// AcquireRequestMsg is the NATS payload for TopicAcquire.
type AcquireRequestMsg struct {
Bridge string `json:"bridge"`
ClientID string `json:"client_id"`
Hostname string `json:"hostname"`
VendorClass string `json:"vendor_class"`
// HWAddr is the MAC to put in the DHCP payload chaddr, formatted as
// 02:aa:bb:cc:dd:ee. Empty means the vpcd Manager derives a synthetic
// MAC from ClientID.
HWAddr string `json:"hw_addr,omitempty"`
PoolName string `json:"pool_name,omitempty"`
}
// AcquireReplyMsg is the NATS reply for TopicAcquire. Error is the only
// field set on failure.
type AcquireReplyMsg struct {
IP string `json:"ip,omitempty"`
SubnetMask string `json:"subnet_mask,omitempty"`
Routers []string `json:"routers,omitempty"`
DNS []string `json:"dns,omitempty"`
ServerID string `json:"server_id,omitempty"`
HWAddr string `json:"hw_addr,omitempty"`
ExpiresUnix int64 `json:"expires_unix,omitempty"`
Error string `json:"error,omitempty"`
}
// ReleaseRequestMsg is the NATS payload for TopicRelease. ClientID must
// match the identifier the lease was acquired with.
type ReleaseRequestMsg struct {
ClientID string `json:"client_id"`
}
// ReleaseReplyMsg is the NATS reply for TopicRelease.
type ReleaseReplyMsg struct {
Error string `json:"error,omitempty"`
}
// LeaseExpiredEvent is the payload fan-out on TopicLeaseExpired.
type LeaseExpiredEvent struct {
ClientID string `json:"client_id"`
PoolName string `json:"pool_name,omitempty"`
IP string `json:"ip,omitempty"`
// Reason is a short machine-readable code (e.g. "renew_failed",
// "nak", "server_unreachable").
Reason string `json:"reason"`
}
+173
View File
@@ -0,0 +1,173 @@
package dhcp
import (
"context"
"fmt"
"time"
"github.com/insomniacslk/dhcp/dhcpv4"
"github.com/insomniacslk/dhcp/dhcpv4/nclient4"
)
// NClient4Client is the production DHCP client backed by
// github.com/insomniacslk/dhcp/dhcpv4/nclient4. Each Acquire/Renew/Release
// opens an AF_PACKET socket on the target bridge for the duration of the
// handshake and closes it when done — no long-lived per-lease process.
type NClient4Client struct {
timeout time.Duration
retry int
}
// NewNClient4 creates an NClient4Client with sensible DORA defaults.
func NewNClient4(timeout time.Duration, retry int) *NClient4Client {
if timeout <= 0 {
timeout = 15 * time.Second
}
if retry <= 0 {
retry = 3
}
return &NClient4Client{timeout: timeout, retry: retry}
}
func (c *NClient4Client) Acquire(ctx context.Context, req AcquireRequest) (*Lease, error) {
if req.Bridge == "" {
return nil, fmt.Errorf("dhcp acquire: bridge is required")
}
if len(req.HWAddr) == 0 {
return nil, fmt.Errorf("dhcp acquire: hw_addr is required")
}
client, err := nclient4.New(req.Bridge,
nclient4.WithHWAddr(req.HWAddr),
nclient4.WithTimeout(c.timeout),
nclient4.WithRetry(c.retry),
)
if err != nil {
return nil, fmt.Errorf("open nclient4 on %s: %w", req.Bridge, err)
}
defer func() { _ = client.Close() }()
lease, err := client.Request(ctx, identityModifiers(req.ClientID, req.Hostname, req.VendorClass)...)
if err != nil {
return nil, fmt.Errorf("dhcp DORA on %s (client=%s): %w", req.Bridge, req.ClientID, err)
}
return leaseFromNClient4(req, lease), nil
}
func (c *NClient4Client) Renew(ctx context.Context, lease *Lease) (*Lease, error) {
if lease == nil {
return nil, fmt.Errorf("dhcp renew: lease is nil")
}
nclient4Lease, err := reconstructNClient4Lease(lease)
if err != nil {
return nil, fmt.Errorf("dhcp renew: %w", err)
}
client, err := nclient4.New(lease.Bridge,
nclient4.WithHWAddr(lease.HWAddr),
nclient4.WithTimeout(c.timeout),
nclient4.WithRetry(c.retry),
)
if err != nil {
return nil, fmt.Errorf("open nclient4 on %s for renew: %w", lease.Bridge, err)
}
defer func() { _ = client.Close() }()
renewed, err := client.Renew(ctx, nclient4Lease,
identityModifiers(lease.ClientID, lease.Hostname, lease.VendorClass)...)
if err != nil {
return nil, fmt.Errorf("dhcp renew on %s (client=%s): %w", lease.Bridge, lease.ClientID, err)
}
return leaseFromNClient4(AcquireRequest{
Bridge: lease.Bridge,
ClientID: lease.ClientID,
Hostname: lease.Hostname,
VendorClass: lease.VendorClass,
HWAddr: lease.HWAddr,
}, renewed), nil
}
func (c *NClient4Client) Release(ctx context.Context, lease *Lease) error {
if lease == nil {
return nil
}
nclient4Lease, err := reconstructNClient4Lease(lease)
if err != nil {
return fmt.Errorf("dhcp release: %w", err)
}
client, err := nclient4.New(lease.Bridge,
nclient4.WithHWAddr(lease.HWAddr),
nclient4.WithTimeout(c.timeout),
)
if err != nil {
return fmt.Errorf("open nclient4 on %s for release: %w", lease.Bridge, err)
}
defer func() { _ = client.Close() }()
if err := client.Release(nclient4Lease,
dhcpv4.WithOption(dhcpv4.OptClientIdentifier([]byte(lease.ClientID)))); err != nil {
return fmt.Errorf("dhcp release on %s (client=%s): %w", lease.Bridge, lease.ClientID, err)
}
return nil
}
// identityModifiers builds the three identifying DHCP options we set on
// every outbound message: option 61 (client-id), option 12 (hostname),
// option 60 (vendor class).
func identityModifiers(clientID, hostname, vendorClass string) []dhcpv4.Modifier {
var mods []dhcpv4.Modifier
if clientID != "" {
mods = append(mods, dhcpv4.WithOption(dhcpv4.OptClientIdentifier([]byte(clientID))))
}
if hostname != "" {
mods = append(mods, dhcpv4.WithOption(dhcpv4.OptHostName(hostname)))
}
if vendorClass != "" {
mods = append(mods, dhcpv4.WithOption(dhcpv4.OptClassIdentifier(vendorClass)))
}
return mods
}
func leaseFromNClient4(req AcquireRequest, in *nclient4.Lease) *Lease {
ack := in.ACK
leaseTime := ack.IPAddressLeaseTime(24 * time.Hour)
return &Lease{
Bridge: req.Bridge,
ClientID: req.ClientID,
Hostname: req.Hostname,
VendorClass: req.VendorClass,
HWAddr: req.HWAddr,
IP: ack.YourIPAddr,
SubnetMask: ack.SubnetMask(),
Routers: ack.Router(),
DNS: ack.DNS(),
ServerID: ack.ServerIdentifier(),
AcquiredAt: in.CreationTime,
LeaseDuration: leaseTime,
T1: ack.IPAddressRenewalTime(leaseTime / 2),
T2: ack.IPAddressRebindingTime(leaseTime * 7 / 8),
RawOffer: in.Offer.ToBytes(),
RawACK: ack.ToBytes(),
}
}
func reconstructNClient4Lease(l *Lease) (*nclient4.Lease, error) {
if len(l.RawOffer) == 0 || len(l.RawACK) == 0 {
return nil, fmt.Errorf("lease is missing raw offer/ack bytes; renewal/release not possible")
}
offer, err := dhcpv4.FromBytes(l.RawOffer)
if err != nil {
return nil, fmt.Errorf("parse stored offer: %w", err)
}
ack, err := dhcpv4.FromBytes(l.RawACK)
if err != nil {
return nil, fmt.Errorf("parse stored ack: %w", err)
}
return &nclient4.Lease{
Offer: offer,
ACK: ack,
CreationTime: l.AcquiredAt,
}, nil
}
+685
View File
@@ -0,0 +1,685 @@
package vpcd
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"math/rand/v2"
"net"
"strings"
"sync"
"time"
"github.com/mulgadc/spinifex/spinifex/migrate"
"github.com/mulgadc/spinifex/spinifex/services/vpcd/dhcp"
"github.com/mulgadc/spinifex/spinifex/utils"
"github.com/nats-io/nats.go"
)
// DHCPManager owns the active DHCP leases held by spinifex-vpcd. It
// services vpc.dhcp.acquire / vpc.dhcp.release NATS requests from the
// daemon-side ExternalIPAM handlers, runs a per-lease renewal goroutine,
// and persists each lease in the spinifex-dhcp-leases KV bucket so that
// a vpcd restart does not forget what has already been allocated upstream.
type DHCPManager struct {
client dhcp.Client
kv nats.KeyValue
nc *nats.Conn
// macForClientID produces the chaddr for a request that came in with
// HWAddr unset. Defaults to generateMAC; tests override.
macForClientID func(clientID string) net.HardwareAddr
// acquireTimeout bounds a single DORA (Acquire) / Renew / Release
// handshake. Independent of the RPC reply timeout on the handler side.
acquireTimeout time.Duration
// jitterFraction is the ± range applied to renewal sleeps, expressed
// as a fraction of T1 (0.1 = ±10%).
jitterFraction float64
rng *rand.Rand
mu sync.Mutex
leases map[string]*managedLease // keyed by ClientID
stopCtx context.Context
stopCancel context.CancelFunc
wg sync.WaitGroup
}
type managedLease struct {
lease *dhcp.Lease
renewStop context.CancelFunc
renewDone chan struct{}
}
// DHCPManagerOption configures DHCPManager at construction.
type DHCPManagerOption func(*DHCPManager)
// WithDHCPMACFunc overrides the default MAC derivation (generateMAC).
func WithDHCPMACFunc(fn func(clientID string) net.HardwareAddr) DHCPManagerOption {
return func(m *DHCPManager) { m.macForClientID = fn }
}
// WithDHCPAcquireTimeout overrides the per-handshake DORA timeout.
func WithDHCPAcquireTimeout(d time.Duration) DHCPManagerOption {
return func(m *DHCPManager) { m.acquireTimeout = d }
}
// WithDHCPJitterFraction overrides the ±fraction applied to renewal sleeps.
func WithDHCPJitterFraction(f float64) DHCPManagerOption {
return func(m *DHCPManager) { m.jitterFraction = f }
}
// WithDHCPRand overrides the random source used for jitter (tests only).
func WithDHCPRand(r *rand.Rand) DHCPManagerOption {
return func(m *DHCPManager) { m.rng = r }
}
// NewDHCPManager creates a Manager wired against js for KV persistence and
// nc for NATS request/reply. The caller still needs to call Bootstrap (to
// rehydrate persisted leases) and Subscribe (to start servicing requests).
func NewDHCPManager(nc *nats.Conn, js nats.JetStreamContext, client dhcp.Client, opts ...DHCPManagerOption) (*DHCPManager, error) {
if nc == nil {
return nil, fmt.Errorf("DHCPManager: nats.Conn is required")
}
if js == nil {
return nil, fmt.Errorf("DHCPManager: JetStreamContext is required")
}
if client == nil {
return nil, fmt.Errorf("DHCPManager: dhcp.Client is required")
}
kv, err := utils.GetOrCreateKVBucket(js, dhcp.KVBucketDHCPLeases, 3)
if err != nil {
return nil, fmt.Errorf("create DHCP lease KV bucket: %w", err)
}
if err := migrate.DefaultRegistry.RunKV(dhcp.KVBucketDHCPLeases, kv, dhcp.KVBucketDHCPLeasesVersion); err != nil {
return nil, fmt.Errorf("migrate %s: %w", dhcp.KVBucketDHCPLeases, err)
}
ctx, cancel := context.WithCancel(context.Background())
m := &DHCPManager{
client: client,
kv: kv,
nc: nc,
acquireTimeout: 15 * time.Second,
jitterFraction: 0.1,
rng: rand.New(rand.NewPCG(uint64(time.Now().UnixNano()), 0xdeadbeef)), //nolint:gosec // non-cryptographic jitter
leases: map[string]*managedLease{},
stopCtx: ctx,
stopCancel: cancel,
macForClientID: func(id string) net.HardwareAddr {
mac, err := net.ParseMAC(generateMAC(id))
if err != nil {
return nil
}
return mac
},
}
for _, opt := range opts {
opt(m)
}
return m, nil
}
// Bootstrap reads every persisted lease from KV and schedules the
// renewal goroutine. Leases already past their expiry are removed and
// a TopicLeaseExpired event is published so the reconcilers upstream
// know they need to re-request.
func (m *DHCPManager) Bootstrap(ctx context.Context) error {
keys, err := m.kv.Keys()
if err != nil {
if errors.Is(err, nats.ErrNoKeysFound) {
slog.Info("DHCP Manager bootstrapped", "leases", 0)
return nil
}
return fmt.Errorf("list DHCP lease keys: %w", err)
}
now := time.Now()
resumed, expired := 0, 0
for _, key := range keys {
if key == "_version" {
continue
}
entry, err := m.kv.Get(key)
if err != nil {
slog.Warn("DHCP Manager: failed to load lease", "client_id", key, "err", err)
continue
}
lease, err := decodeLease(entry.Value())
if err != nil {
slog.Warn("DHCP Manager: failed to decode lease", "client_id", key, "err", err)
continue
}
if now.After(lease.ExpiresAt()) {
_ = m.kv.Delete(key)
m.publishExpired(lease, "", "bootstrap_past_expiry")
expired++
continue
}
m.trackAndRenew(lease)
resumed++
}
slog.Info("DHCP Manager bootstrapped", "leases", resumed, "expired", expired)
return nil
}
// Subscribe registers queue-subscribed handlers on vpc.dhcp.acquire and
// vpc.dhcp.release. Queue group "vpcd-dhcp-workers" matches the existing
// vpcd-workers convention.
func (m *DHCPManager) Subscribe(nc *nats.Conn) ([]*nats.Subscription, error) {
var subs []*nats.Subscription
acquire, err := nc.QueueSubscribe(dhcp.TopicAcquire, "vpcd-dhcp-workers", m.handleAcquire)
if err != nil {
return nil, fmt.Errorf("subscribe %s: %w", dhcp.TopicAcquire, err)
}
subs = append(subs, acquire)
slog.Info("DHCP Manager subscribed", "topic", dhcp.TopicAcquire)
release, err := nc.QueueSubscribe(dhcp.TopicRelease, "vpcd-dhcp-workers", m.handleRelease)
if err != nil {
_ = acquire.Unsubscribe()
return nil, fmt.Errorf("subscribe %s: %w", dhcp.TopicRelease, err)
}
subs = append(subs, release)
slog.Info("DHCP Manager subscribed", "topic", dhcp.TopicRelease)
return subs, nil
}
// Close stops all renewal goroutines and waits for them to exit.
func (m *DHCPManager) Close() {
m.stopCancel()
m.wg.Wait()
}
// handleAcquire processes a vpc.dhcp.acquire request. Idempotent: if we
// already hold a valid lease for the requested ClientID we return it
// without a fresh DORA. Lets the daemon-side CAS retry loop be safe.
func (m *DHCPManager) handleAcquire(msg *nats.Msg) {
var req dhcp.AcquireRequestMsg
if err := json.Unmarshal(msg.Data, &req); err != nil {
m.replyAcquireError(msg, fmt.Sprintf("invalid acquire request: %v", err))
return
}
if req.Bridge == "" || req.ClientID == "" {
m.replyAcquireError(msg, "acquire: bridge and client_id are required")
return
}
if existing, ok := m.get(req.ClientID); ok {
if time.Now().Before(existing.ExpiresAt()) {
m.replyAcquire(msg, existing)
return
}
m.forget(req.ClientID, "stale_existing")
}
hwAddr, err := resolveHWAddr(req.HWAddr, req.ClientID, m.macForClientID)
if err != nil {
m.replyAcquireError(msg, err.Error())
return
}
ctx, cancel := context.WithTimeout(m.stopCtx, m.acquireTimeout)
defer cancel()
lease, err := m.client.Acquire(ctx, dhcp.AcquireRequest{
Bridge: req.Bridge,
ClientID: req.ClientID,
Hostname: req.Hostname,
VendorClass: req.VendorClass,
HWAddr: hwAddr,
})
if err != nil {
m.replyAcquireError(msg, err.Error())
return
}
if err := m.persistLease(lease); err != nil {
slog.Warn("DHCP Manager: persist lease failed; lease will not survive restart", "client_id", req.ClientID, "err", err)
}
m.trackAndRenew(lease)
slog.Info("DHCP Manager acquired lease",
"client_id", lease.ClientID, "ip", lease.IP.String(),
"bridge", lease.Bridge, "server_id", lease.ServerID.String(),
"expires_in", time.Until(lease.ExpiresAt()))
m.replyAcquire(msg, lease)
}
// handleRelease processes a vpc.dhcp.release request.
func (m *DHCPManager) handleRelease(msg *nats.Msg) {
var req dhcp.ReleaseRequestMsg
if err := json.Unmarshal(msg.Data, &req); err != nil {
m.replyRelease(msg, fmt.Sprintf("invalid release request: %v", err))
return
}
if req.ClientID == "" {
m.replyRelease(msg, "release: client_id is required")
return
}
lease, ok := m.get(req.ClientID)
if !ok {
// No active lease; treat as a no-op so the daemon can call release
// in clean-up paths without worrying about order.
m.replyRelease(msg, "")
return
}
ctx, cancel := context.WithTimeout(m.stopCtx, m.acquireTimeout)
defer cancel()
if err := m.client.Release(ctx, lease); err != nil {
slog.Warn("DHCP Manager: release failed", "client_id", req.ClientID, "err", err)
// Still forget locally — upstream server may or may not have seen
// the RELEASE, but holding on to a lease we've been told to drop
// serves no purpose.
}
m.forget(req.ClientID, "released")
slog.Info("DHCP Manager released lease", "client_id", req.ClientID)
m.replyRelease(msg, "")
}
// trackAndRenew registers the lease in the in-memory map and starts the
// per-lease renewal goroutine. Replaces any prior tracker for the same
// ClientID.
func (m *DHCPManager) trackAndRenew(lease *dhcp.Lease) {
m.mu.Lock()
if prev, ok := m.leases[lease.ClientID]; ok && prev.renewStop != nil {
prev.renewStop()
m.mu.Unlock()
<-prev.renewDone
m.mu.Lock()
}
// cancel is stored in tracker.renewStop and fired from forget() or Close().
ctx, cancel := context.WithCancel(m.stopCtx) //nolint:gosec // G118: cancel stored in tracker.renewStop, fired via forget()/Close()
tracker := &managedLease{
lease: lease,
renewStop: cancel,
renewDone: make(chan struct{}),
}
m.leases[lease.ClientID] = tracker
m.mu.Unlock()
m.wg.Add(1)
go m.renewLoop(ctx, tracker)
}
// renewLoop drives one lease's renewal lifecycle. Sleeps until T1 (with
// ±jitter), attempts Renew; on failure retries at T2; on terminal failure
// past the expiry, publishes TopicLeaseExpired and forgets the lease.
func (m *DHCPManager) renewLoop(ctx context.Context, tracker *managedLease) {
defer m.wg.Done()
defer close(tracker.renewDone)
for {
lease := m.snapshotLease(tracker)
if lease == nil {
return
}
// Phase 1 — wait for T1 (± jitter) and try RENEW.
wait := m.jitter(time.Until(lease.RenewAt()))
if !sleep(ctx, wait) {
return
}
renewed, err := m.tryRenew(ctx, lease)
if err == nil {
tracker.lease = renewed
if perr := m.persistLease(renewed); perr != nil {
slog.Warn("DHCP Manager: persist renewed lease failed", "client_id", renewed.ClientID, "err", perr)
}
continue
}
slog.Warn("DHCP Manager: renew failed, will retry at T2", "client_id", lease.ClientID, "err", err)
// Phase 2 — retry until T2.
wait = m.jitter(time.Until(lease.RebindAt()))
if !sleep(ctx, wait) {
return
}
renewed, err = m.tryRenew(ctx, lease)
if err == nil {
tracker.lease = renewed
if perr := m.persistLease(renewed); perr != nil {
slog.Warn("DHCP Manager: persist rebound lease failed", "client_id", renewed.ClientID, "err", perr)
}
continue
}
// Phase 3 — wait out the rest of the lease; on expiry, emit
// lease-expired and drop.
wait = time.Until(lease.ExpiresAt())
if !sleep(ctx, wait) {
return
}
slog.Warn("DHCP Manager: lease expired without renewal", "client_id", lease.ClientID, "ip", lease.IP.String(), "err", err)
m.publishExpired(lease, "", classifyRenewErr(err))
m.forget(lease.ClientID, "expired")
return
}
}
// tryRenew calls the backend Renew under a bounded context.
func (m *DHCPManager) tryRenew(parent context.Context, lease *dhcp.Lease) (*dhcp.Lease, error) {
ctx, cancel := context.WithTimeout(parent, m.acquireTimeout)
defer cancel()
return m.client.Renew(ctx, lease)
}
// snapshotLease returns the tracker's current lease under the lock.
func (m *DHCPManager) snapshotLease(tracker *managedLease) *dhcp.Lease {
m.mu.Lock()
defer m.mu.Unlock()
return tracker.lease
}
// get returns the current lease for a ClientID, if tracked.
func (m *DHCPManager) get(clientID string) (*dhcp.Lease, bool) {
m.mu.Lock()
defer m.mu.Unlock()
t, ok := m.leases[clientID]
if !ok {
return nil, false
}
return t.lease, true
}
// forget removes a lease from the tracking map, KV and stops its renewal
// goroutine.
func (m *DHCPManager) forget(clientID, reason string) {
m.mu.Lock()
t, ok := m.leases[clientID]
if ok {
delete(m.leases, clientID)
}
m.mu.Unlock()
if ok && t.renewStop != nil {
t.renewStop()
}
if err := m.kv.Delete(clientID); err != nil && !errors.Is(err, nats.ErrKeyNotFound) {
slog.Debug("DHCP Manager: delete KV key failed", "client_id", clientID, "reason", reason, "err", err)
}
}
// persistLease writes the lease state to the KV bucket under ClientID.
func (m *DHCPManager) persistLease(lease *dhcp.Lease) error {
data, err := encodeLease(lease)
if err != nil {
return err
}
if _, err := m.kv.Put(lease.ClientID, data); err != nil {
return fmt.Errorf("put lease %s: %w", lease.ClientID, err)
}
return nil
}
func (m *DHCPManager) publishExpired(lease *dhcp.Lease, poolName, reason string) {
evt := dhcp.LeaseExpiredEvent{
ClientID: lease.ClientID,
PoolName: poolName,
IP: lease.IP.String(),
Reason: reason,
}
data, err := json.Marshal(evt)
if err != nil {
slog.Warn("DHCP Manager: marshal lease-expired event failed", "client_id", lease.ClientID, "err", err)
return
}
if err := m.nc.Publish(dhcp.TopicLeaseExpired, data); err != nil {
slog.Warn("DHCP Manager: publish lease-expired failed", "client_id", lease.ClientID, "err", err)
}
}
func (m *DHCPManager) replyAcquire(msg *nats.Msg, lease *dhcp.Lease) {
reply := dhcp.AcquireReplyMsg{
IP: lease.IP.String(),
SubnetMask: net.IP(lease.SubnetMask).String(),
Routers: ipsToStrings(lease.Routers),
DNS: ipsToStrings(lease.DNS),
ServerID: lease.ServerID.String(),
HWAddr: lease.HWAddr.String(),
ExpiresUnix: lease.ExpiresAt().Unix(),
}
respondJSON(msg, reply)
}
func (m *DHCPManager) replyAcquireError(msg *nats.Msg, errMsg string) {
respondJSON(msg, dhcp.AcquireReplyMsg{Error: errMsg})
}
func (m *DHCPManager) replyRelease(msg *nats.Msg, errMsg string) {
respondJSON(msg, dhcp.ReleaseReplyMsg{Error: errMsg})
}
// jitter applies ±jitterFraction of d to produce an actual sleep duration.
// Negative d is clamped to 0.
func (m *DHCPManager) jitter(d time.Duration) time.Duration {
if d <= 0 {
return 0
}
if m.jitterFraction <= 0 {
return d
}
spread := float64(d) * m.jitterFraction
offset := (m.rng.Float64()*2 - 1) * spread
out := time.Duration(float64(d) + offset)
if out < 0 {
return 0
}
return out
}
// resolveHWAddr parses the hw_addr string supplied in the NATS request,
// falling back to a derived MAC when the request carries no value.
func resolveHWAddr(raw, clientID string, derive func(string) net.HardwareAddr) (net.HardwareAddr, error) {
if raw != "" {
mac, err := net.ParseMAC(raw)
if err != nil {
return nil, fmt.Errorf("parse hw_addr %q: %w", raw, err)
}
return mac, nil
}
if derive == nil {
return nil, fmt.Errorf("hw_addr is empty and no derivation function configured")
}
mac := derive(clientID)
if len(mac) == 0 {
return nil, fmt.Errorf("derive hw_addr for client %q: empty result", clientID)
}
return mac, nil
}
// classifyRenewErr maps a renew error to a short reason code on the
// lease-expired event. Kept coarse; callers that care about specifics
// can re-log from their side.
func classifyRenewErr(err error) string {
if err == nil {
return "unknown"
}
// nclient4 returns *nclient4.ErrNak on NAK. Rather than taking a
// dependency on its private type here, match the known message.
msg := err.Error()
if strings.Contains(msg, "NAK") || strings.Contains(msg, "Nak") {
return "nak"
}
if strings.Contains(msg, "timeout") || strings.Contains(msg, "deadline exceeded") || strings.Contains(msg, "i/o timeout") {
return "server_unreachable"
}
return "renew_failed"
}
func ipsToStrings(ips []net.IP) []string {
if len(ips) == 0 {
return nil
}
out := make([]string, 0, len(ips))
for _, ip := range ips {
out = append(out, ip.String())
}
return out
}
// respondJSON marshals v and sends it as the reply. If the message has
// no Reply subject (fire-and-forget), drops the reply silently.
func respondJSON(msg *nats.Msg, v any) {
if msg.Reply == "" {
return
}
data, err := json.Marshal(v)
if err != nil {
slog.Warn("DHCP Manager: marshal reply failed", "err", err)
return
}
if err := msg.Respond(data); err != nil {
slog.Warn("DHCP Manager: respond failed", "err", err)
}
}
// sleep blocks until d elapses or ctx is done. Returns false if ctx was
// cancelled (caller should exit).
func sleep(ctx context.Context, d time.Duration) bool {
if d <= 0 {
select {
case <-ctx.Done():
return false
default:
return true
}
}
t := time.NewTimer(d)
defer t.Stop()
select {
case <-ctx.Done():
return false
case <-t.C:
return true
}
}
// --- KV encoding ---
// leaseRecord is the JSON representation persisted in KV and used to
// rehydrate on vpcd startup. Explicit format rather than gob-encoding
// *dhcp.Lease so a future Go type change doesn't invalidate every
// in-flight lease.
type leaseRecord struct {
Version int `json:"version"`
Bridge string `json:"bridge"`
ClientID string `json:"client_id"`
Hostname string `json:"hostname,omitempty"`
VendorClass string `json:"vendor_class,omitempty"`
HWAddr string `json:"hw_addr"`
IP string `json:"ip"`
SubnetMask string `json:"subnet_mask,omitempty"`
Routers []string `json:"routers,omitempty"`
DNS []string `json:"dns,omitempty"`
ServerID string `json:"server_id,omitempty"`
AcquiredUnix int64 `json:"acquired_unix"`
LeaseSeconds int64 `json:"lease_seconds"`
T1Seconds int64 `json:"t1_seconds"`
T2Seconds int64 `json:"t2_seconds"`
RawOfferB64 []byte `json:"raw_offer,omitempty"`
RawACKB64 []byte `json:"raw_ack,omitempty"`
}
const leaseRecordVersion = 1
func encodeLease(l *dhcp.Lease) ([]byte, error) {
rec := leaseRecord{
Version: leaseRecordVersion,
Bridge: l.Bridge,
ClientID: l.ClientID,
Hostname: l.Hostname,
VendorClass: l.VendorClass,
HWAddr: l.HWAddr.String(),
IP: l.IP.String(),
SubnetMask: maskString(l.SubnetMask),
Routers: ipsToStrings(l.Routers),
DNS: ipsToStrings(l.DNS),
ServerID: l.ServerID.String(),
AcquiredUnix: l.AcquiredAt.Unix(),
LeaseSeconds: int64(l.LeaseDuration / time.Second),
T1Seconds: int64(l.T1 / time.Second),
T2Seconds: int64(l.T2 / time.Second),
RawOfferB64: l.RawOffer,
RawACKB64: l.RawACK,
}
return json.Marshal(rec)
}
func decodeLease(data []byte) (*dhcp.Lease, error) {
var rec leaseRecord
if err := json.Unmarshal(data, &rec); err != nil {
return nil, fmt.Errorf("unmarshal lease: %w", err)
}
if rec.Version != leaseRecordVersion {
return nil, fmt.Errorf("unsupported lease record version %d", rec.Version)
}
mac, err := net.ParseMAC(rec.HWAddr)
if err != nil {
return nil, fmt.Errorf("parse hw_addr %q: %w", rec.HWAddr, err)
}
return &dhcp.Lease{
Bridge: rec.Bridge,
ClientID: rec.ClientID,
Hostname: rec.Hostname,
VendorClass: rec.VendorClass,
HWAddr: mac,
IP: net.ParseIP(rec.IP),
SubnetMask: parseMask(rec.SubnetMask),
Routers: parseIPs(rec.Routers),
DNS: parseIPs(rec.DNS),
ServerID: net.ParseIP(rec.ServerID),
AcquiredAt: time.Unix(rec.AcquiredUnix, 0),
LeaseDuration: time.Duration(rec.LeaseSeconds) * time.Second,
T1: time.Duration(rec.T1Seconds) * time.Second,
T2: time.Duration(rec.T2Seconds) * time.Second,
RawOffer: rec.RawOfferB64,
RawACK: rec.RawACKB64,
}, nil
}
func maskString(m net.IPMask) string {
if len(m) == 0 {
return ""
}
return net.IP(m).String()
}
func parseMask(s string) net.IPMask {
ip := net.ParseIP(s)
if ip == nil {
return nil
}
ip4 := ip.To4()
if ip4 == nil {
return nil
}
return net.IPv4Mask(ip4[0], ip4[1], ip4[2], ip4[3])
}
func parseIPs(ss []string) []net.IP {
if len(ss) == 0 {
return nil
}
out := make([]net.IP, 0, len(ss))
for _, s := range ss {
if ip := net.ParseIP(s); ip != nil {
out = append(out, ip)
}
}
return out
}
+354
View File
@@ -0,0 +1,354 @@
package vpcd
import (
"context"
"encoding/json"
"errors"
"net"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/mulgadc/spinifex/spinifex/services/vpcd/dhcp"
"github.com/mulgadc/spinifex/spinifex/testutil"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/require"
)
// newTestDHCPManager spins up an embedded JetStream, wires a Fake client,
// and returns the Manager along with the NATS connection. Cleanup of NATS
// is handled by testutil; the Manager is closed via t.Cleanup.
func newTestDHCPManager(t *testing.T, opts ...DHCPManagerOption) (*DHCPManager, *nats.Conn, *dhcp.Fake) {
t.Helper()
_, nc, js := testutil.StartTestJetStream(t)
fake := dhcp.NewFake()
m, err := NewDHCPManager(nc, js, fake, opts...)
require.NoError(t, err)
t.Cleanup(m.Close)
subs, err := m.Subscribe(nc)
require.NoError(t, err)
t.Cleanup(func() {
for _, s := range subs {
_ = s.Unsubscribe()
}
})
return m, nc, fake
}
func requestAcquire(t *testing.T, nc *nats.Conn, req dhcp.AcquireRequestMsg) dhcp.AcquireReplyMsg {
t.Helper()
data, err := json.Marshal(req)
require.NoError(t, err)
msg, err := nc.Request(dhcp.TopicAcquire, data, 5*time.Second)
require.NoError(t, err)
var reply dhcp.AcquireReplyMsg
require.NoError(t, json.Unmarshal(msg.Data, &reply))
return reply
}
func requestRelease(t *testing.T, nc *nats.Conn, clientID string) dhcp.ReleaseReplyMsg {
t.Helper()
data, err := json.Marshal(dhcp.ReleaseRequestMsg{ClientID: clientID})
require.NoError(t, err)
msg, err := nc.Request(dhcp.TopicRelease, data, 5*time.Second)
require.NoError(t, err)
var reply dhcp.ReleaseReplyMsg
require.NoError(t, json.Unmarshal(msg.Data, &reply))
return reply
}
func TestDHCPManager_AcquireReturnsLease(t *testing.T) {
_, nc, fake := newTestDHCPManager(t)
reply := requestAcquire(t, nc, dhcp.AcquireRequestMsg{
Bridge: "br-wan", ClientID: "eni-1", Hostname: "eni-1", VendorClass: "i-abc",
})
require.Empty(t, reply.Error, "unexpected error: %s", reply.Error)
require.Equal(t, "192.0.2.100", reply.IP)
require.NotEmpty(t, reply.ServerID)
require.NotZero(t, reply.ExpiresUnix)
require.NotEmpty(t, reply.HWAddr)
require.Equal(t, 1, fake.AcquireCount())
}
func TestDHCPManager_AcquireIdempotentWhileLeaseActive(t *testing.T) {
_, nc, fake := newTestDHCPManager(t)
req := dhcp.AcquireRequestMsg{Bridge: "br-wan", ClientID: "eni-idem"}
first := requestAcquire(t, nc, req)
require.Empty(t, first.Error)
second := requestAcquire(t, nc, req)
require.Empty(t, second.Error)
require.Equal(t, first.IP, second.IP, "idempotent acquire should return the same IP")
require.Equal(t, 1, fake.AcquireCount(), "second request must not trigger a fresh DORA")
}
func TestDHCPManager_AcquireFailureSurfacesError(t *testing.T) {
_, nc, fake := newTestDHCPManager(t)
fake.AcquireHook = func(dhcp.AcquireRequest) (*dhcp.Lease, error) {
return nil, errors.New("server unreachable")
}
reply := requestAcquire(t, nc, dhcp.AcquireRequestMsg{Bridge: "br-wan", ClientID: "eni-err"})
require.Contains(t, reply.Error, "server unreachable")
require.Empty(t, reply.IP)
}
func TestDHCPManager_ReleaseRemovesLease(t *testing.T) {
m, nc, fake := newTestDHCPManager(t)
req := dhcp.AcquireRequestMsg{Bridge: "br-wan", ClientID: "eni-release"}
require.Empty(t, requestAcquire(t, nc, req).Error)
reply := requestRelease(t, nc, req.ClientID)
require.Empty(t, reply.Error)
require.Equal(t, 1, fake.ReleaseCount())
_, ok := m.get("eni-release")
require.False(t, ok, "lease should be forgotten after Release")
}
func TestDHCPManager_ReleaseUnknownIsNoop(t *testing.T) {
_, nc, fake := newTestDHCPManager(t)
reply := requestRelease(t, nc, "nonexistent")
require.Empty(t, reply.Error)
require.Equal(t, 0, fake.ReleaseCount())
}
func TestDHCPManager_AcquireRejectsMissingFields(t *testing.T) {
_, nc, _ := newTestDHCPManager(t)
reply := requestAcquire(t, nc, dhcp.AcquireRequestMsg{Bridge: "", ClientID: "eni-x"})
require.Contains(t, reply.Error, "bridge and client_id are required")
}
func TestDHCPManager_HWAddrDerivedFromClientID(t *testing.T) {
_, nc, fake := newTestDHCPManager(t)
reply := requestAcquire(t, nc, dhcp.AcquireRequestMsg{Bridge: "br-wan", ClientID: "eni-mac"})
require.Empty(t, reply.Error)
lease, ok := fake.HeldLease("eni-mac")
require.True(t, ok)
// generateMAC uses the 02:00:00 locally-administered unicast prefix.
require.Equal(t, "02:00:00", lease.HWAddr.String()[:8])
}
func TestDHCPManager_HWAddrExplicitlySuppliedIsUsed(t *testing.T) {
_, nc, fake := newTestDHCPManager(t)
reply := requestAcquire(t, nc, dhcp.AcquireRequestMsg{
Bridge: "br-wan", ClientID: "eni-mac-explicit", HWAddr: "02:aa:bb:cc:dd:ee",
})
require.Empty(t, reply.Error)
lease, _ := fake.HeldLease("eni-mac-explicit")
require.Equal(t, "02:aa:bb:cc:dd:ee", lease.HWAddr.String())
}
func TestDHCPManager_RenewalFiresAndUpdatesLease(t *testing.T) {
tmpl := dhcp.DefaultLeaseTemplate()
tmpl.LeaseDuration = 40 * time.Millisecond
fake := dhcp.NewFake()
fake.SetDefaultLease(tmpl)
_, nc, js := testutil.StartTestJetStream(t)
m, err := NewDHCPManager(nc, js, fake, WithDHCPJitterFraction(0))
require.NoError(t, err)
t.Cleanup(m.Close)
subs, err := m.Subscribe(nc)
require.NoError(t, err)
t.Cleanup(func() {
for _, s := range subs {
_ = s.Unsubscribe()
}
})
reply := requestAcquire(t, nc, dhcp.AcquireRequestMsg{Bridge: "br-wan", ClientID: "eni-renew"})
require.Empty(t, reply.Error)
// T1 = lease/2 = 20 ms. Wait a few multiples to catch at least one renew.
require.Eventually(t, func() bool { return fake.RenewCount() >= 1 }, 2*time.Second, 10*time.Millisecond)
}
func TestDHCPManager_RenewFailureEmitsLeaseExpired(t *testing.T) {
tmpl := dhcp.DefaultLeaseTemplate()
tmpl.LeaseDuration = 60 * time.Millisecond
fake := dhcp.NewFake()
fake.SetDefaultLease(tmpl)
fake.RenewHook = func(*dhcp.Lease) (*dhcp.Lease, error) {
return nil, errors.New("server NAK")
}
_, nc, js := testutil.StartTestJetStream(t)
m, err := NewDHCPManager(nc, js, fake, WithDHCPJitterFraction(0))
require.NoError(t, err)
t.Cleanup(m.Close)
var events atomic.Int32
expiredMsg := make(chan dhcp.LeaseExpiredEvent, 1)
sub, err := nc.Subscribe(dhcp.TopicLeaseExpired, func(msg *nats.Msg) {
events.Add(1)
var evt dhcp.LeaseExpiredEvent
if err := json.Unmarshal(msg.Data, &evt); err == nil {
select {
case expiredMsg <- evt:
default:
}
}
})
require.NoError(t, err)
t.Cleanup(func() { _ = sub.Unsubscribe() })
subs, err := m.Subscribe(nc)
require.NoError(t, err)
t.Cleanup(func() {
for _, s := range subs {
_ = s.Unsubscribe()
}
})
reply := requestAcquire(t, nc, dhcp.AcquireRequestMsg{Bridge: "br-wan", ClientID: "eni-expire"})
require.Empty(t, reply.Error)
// T1=30ms → try, fail. T2=~52ms → try, fail. Expires at 60ms.
select {
case evt := <-expiredMsg:
require.Equal(t, "eni-expire", evt.ClientID)
require.Equal(t, "nak", evt.Reason)
case <-time.After(2 * time.Second):
t.Fatalf("expected lease-expired event (events=%d)", events.Load())
}
}
func TestDHCPManager_BootstrapRehydratesLease(t *testing.T) {
_, nc, js := testutil.StartTestJetStream(t)
fake := dhcp.NewFake()
m1, err := NewDHCPManager(nc, js, fake)
require.NoError(t, err)
subs, err := m1.Subscribe(nc)
require.NoError(t, err)
reply := requestAcquire(t, nc, dhcp.AcquireRequestMsg{Bridge: "br-wan", ClientID: "eni-boot"})
require.Empty(t, reply.Error)
// Close first manager; KV entry should survive.
for _, s := range subs {
_ = s.Unsubscribe()
}
m1.Close()
// Second manager picks up the lease on Bootstrap.
fake2 := dhcp.NewFake()
m2, err := NewDHCPManager(nc, js, fake2)
require.NoError(t, err)
t.Cleanup(m2.Close)
require.NoError(t, m2.Bootstrap(context.Background()))
lease, ok := m2.get("eni-boot")
require.True(t, ok, "manager should rehydrate persisted lease")
require.Equal(t, "192.0.2.100", lease.IP.String())
require.Equal(t, 0, fake2.AcquireCount(), "no fresh DORA on bootstrap")
}
func TestDHCPManager_BootstrapDropsExpiredLeases(t *testing.T) {
_, nc, js := testutil.StartTestJetStream(t)
fake := dhcp.NewFake()
m1, err := NewDHCPManager(nc, js, fake)
require.NoError(t, err)
subs, err := m1.Subscribe(nc)
require.NoError(t, err)
reply := requestAcquire(t, nc, dhcp.AcquireRequestMsg{Bridge: "br-wan", ClientID: "eni-stale"})
require.Empty(t, reply.Error)
for _, s := range subs {
_ = s.Unsubscribe()
}
m1.Close()
// Rewrite the persisted record so AcquiredAt + LeaseDuration is in the past.
kv, err := js.KeyValue(dhcp.KVBucketDHCPLeases)
require.NoError(t, err)
entry, err := kv.Get("eni-stale")
require.NoError(t, err)
var rec map[string]any
require.NoError(t, json.Unmarshal(entry.Value(), &rec))
rec["acquired_unix"] = time.Now().Add(-2 * time.Hour).Unix()
rec["lease_seconds"] = int64(60)
raw, err := json.Marshal(rec)
require.NoError(t, err)
_, err = kv.Put("eni-stale", raw)
require.NoError(t, err)
fake2 := dhcp.NewFake()
m2, err := NewDHCPManager(nc, js, fake2)
require.NoError(t, err)
t.Cleanup(m2.Close)
var expired sync.WaitGroup
expired.Add(1)
sub, err := nc.Subscribe(dhcp.TopicLeaseExpired, func(msg *nats.Msg) {
var evt dhcp.LeaseExpiredEvent
if err := json.Unmarshal(msg.Data, &evt); err == nil && evt.ClientID == "eni-stale" {
expired.Done()
}
})
require.NoError(t, err)
t.Cleanup(func() { _ = sub.Unsubscribe() })
require.NoError(t, m2.Bootstrap(context.Background()))
done := make(chan struct{})
go func() { expired.Wait(); close(done) }()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("expected lease-expired event for stale lease")
}
_, err = kv.Get("eni-stale")
require.ErrorIs(t, err, nats.ErrKeyNotFound)
}
func TestDHCPManager_JitterWithinBounds(t *testing.T) {
_, nc, js := testutil.StartTestJetStream(t)
m, err := NewDHCPManager(nc, js, dhcp.NewFake(), WithDHCPJitterFraction(0.1))
require.NoError(t, err)
t.Cleanup(m.Close)
base := 100 * time.Millisecond
minOK := time.Duration(float64(base) * 0.9)
maxOK := time.Duration(float64(base) * 1.1)
for i := range 100 {
got := m.jitter(base)
if got < minOK || got > maxOK {
t.Fatalf("iteration %d: jitter produced %v, outside [%v,%v]", i, got, minOK, maxOK)
}
}
}
func TestDHCPManager_EncodeDecodeLeaseRoundTrip(t *testing.T) {
mac, _ := net.ParseMAC("02:00:00:de:ad:be")
in := &dhcp.Lease{
Bridge: "br-wan", ClientID: "eni-rt", Hostname: "eni-rt", VendorClass: "i-xyz",
HWAddr: mac,
IP: net.ParseIP("192.0.2.50"),
SubnetMask: net.CIDRMask(24, 32),
Routers: []net.IP{net.ParseIP("192.0.2.1")},
DNS: []net.IP{net.ParseIP("1.1.1.1")},
ServerID: net.ParseIP("192.0.2.1"),
AcquiredAt: time.Unix(1_700_000_000, 0).UTC(),
LeaseDuration: time.Hour,
T1: 30 * time.Minute,
T2: 52 * time.Minute,
RawOffer: []byte{0x01, 0x02},
RawACK: []byte{0x03, 0x04},
}
data, err := encodeLease(in)
require.NoError(t, err)
out, err := decodeLease(data)
require.NoError(t, err)
require.Equal(t, in.ClientID, out.ClientID)
require.Equal(t, in.IP.String(), out.IP.String())
require.Equal(t, in.LeaseDuration, out.LeaseDuration)
require.Equal(t, in.HWAddr.String(), out.HWAddr.String())
require.Equal(t, in.RawACK, out.RawACK)
require.Equal(t, in.RawOffer, out.RawOffer)
}
+36 -1
View File
@@ -10,7 +10,9 @@ import (
"path/filepath"
"strings"
"syscall"
"time"
"github.com/mulgadc/spinifex/spinifex/services/vpcd/dhcp"
"github.com/mulgadc/spinifex/spinifex/utils"
)
@@ -365,7 +367,40 @@ func launchService(cfg *Config) error {
// Runs after subscribing so new events are not missed during reconciliation.
ReconcileFromKV(ctx, nc, topo)
slog.Info("vpcd service started, waiting for VPC lifecycle events", "subscriptions", len(subs))
// DHCP manager: services vpc.dhcp.acquire/release requests from the
// daemon-side ExternalIPAM handlers, runs a renewal goroutine per lease,
// and persists leases in spinifex-dhcp-leases KV. Started unconditionally
// — pools with source="static" never issue acquire requests, so the
// Manager sits idle until something actually wants DHCP.
js, err := nc.JetStream()
if err != nil {
slog.Error("Failed to get JetStream context for DHCP manager", "err", err)
return err
}
dhcpManager, err := NewDHCPManager(nc, js, dhcp.NewNClient4(15*time.Second, 3))
if err != nil {
slog.Error("Failed to create DHCP manager", "err", err)
return err
}
defer dhcpManager.Close()
if err := dhcpManager.Bootstrap(ctx); err != nil {
slog.Error("DHCP manager bootstrap failed", "err", err)
return err
}
dhcpSubs, err := dhcpManager.Subscribe(nc)
if err != nil {
slog.Error("DHCP manager subscribe failed", "err", err)
return err
}
defer func() {
for _, s := range dhcpSubs {
_ = s.Unsubscribe()
}
}()
slog.Info("vpcd service started, waiting for VPC lifecycle events",
"subscriptions", len(subs), "dhcp_subscriptions", len(dhcpSubs))
// Wait for shutdown signal
sigChan := make(chan os.Signal, 1)