diff --git a/README.md b/README.md index 48ccb7a..5cb6849 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,12 @@ The IP address to lease shall be passed as a string in `bluefield_config.yaml` g ```yaml bluefieldIP: 2001:db8::1 ``` +The leased address preferred and valid lifetimes default to 24 hours and are optionally configurable: +```yaml +leaseTimes: + preferredLifetime: 24h + validLifetime: 24h +``` ### Notes @@ -111,6 +117,12 @@ Providing the length in `onmetal_config.yaml` goes as follows: prefixDelegation: length: 64 ``` +The leased address preferred and valid lifetimes default to 24 hours and are optionally configurable: +```yaml +leaseTimes: + preferredLifetime: 24h + validLifetime: 24h +``` ### Notes - supports only IPv6 - IPv6 relays are mandatory @@ -131,6 +143,12 @@ subnetLabels: - key: foo value: bar ``` +The leased address preferred and valid lifetimes default to 24 hours and are optionally configurable: +```yaml +leaseTimes: + preferredLifetime: 24h + validLifetime: 24h +``` ### Notes - supports both IPv4 and IPv6 - IPv6 relays are supported, IPv4 are not @@ -258,7 +276,13 @@ The Stateless plugin derives deterministic IPv6 addresses from DHCPv6 relay mess For example, given a link address of `2001:db8:1111:2222:3333::` and a MAC of `aa:bb:cc:dd:ee:ff`, the resulting address is `2001:db8:1111:2222:3333:aabb:ccdd:eeff`. -Addresses are leased as [non temporary IPv6 addresses](https://datatracker.ietf.org/doc/html/rfc8415#section-6.2) with a 24-hour lifetime. +Addresses are leased as [non temporary IPv6 addresses](https://datatracker.ietf.org/doc/html/rfc8415#section-6.2). The preferred and valid lifetimes default to 24 hours and are configurable via an optional config file (`stateless_config.yaml`): +```yaml +leaseTimes: + preferredLifetime: 24h + validLifetime: 24h +``` +When no config file is provided, the defaults (24h) are used. ### Notes - supports IPv6 only diff --git a/example/bluefield_config.yaml b/example/bluefield_config.yaml index 5036c87..54f8100 100644 --- a/example/bluefield_config.yaml +++ b/example/bluefield_config.yaml @@ -1 +1,5 @@ -bluefieldIP: 2001:db8::1 \ No newline at end of file +bluefieldIP: 2001:db8::1 +# leaseTimes is optional; both default to 24h when omitted. +leaseTimes: + preferredLifetime: 24h + validLifetime: 24h diff --git a/example/onmetal_config.yaml b/example/onmetal_config.yaml index 7f72aa1..0f11074 100644 --- a/example/onmetal_config.yaml +++ b/example/onmetal_config.yaml @@ -1,2 +1,6 @@ prefixDelegation: - length: 64 \ No newline at end of file + length: 64 +# leaseTimes is optional; both default to 24h when omitted. +leaseTimes: + preferredLifetime: 24h + validLifetime: 24h diff --git a/example/oob_config.yaml b/example/oob_config.yaml index 39f48ae..bd5d42d 100644 --- a/example/oob_config.yaml +++ b/example/oob_config.yaml @@ -3,4 +3,8 @@ subnetLabels: - key: dhcp value: "true" - key: key-2 - value: value-2 \ No newline at end of file + value: value-2 +# leaseTimes is optional; both default to 24h when omitted. +leaseTimes: + preferredLifetime: 24h + validLifetime: 24h diff --git a/example/stateless_config.yaml b/example/stateless_config.yaml new file mode 100644 index 0000000..967c88a --- /dev/null +++ b/example/stateless_config.yaml @@ -0,0 +1,4 @@ +# Lease times are optional; both default to 24h when omitted. +leaseTimes: + preferredLifetime: 24h + validLifetime: 24h diff --git a/internal/api/bluefield_config.go b/internal/api/bluefield_config.go index 688432b..5ceb218 100644 --- a/internal/api/bluefield_config.go +++ b/internal/api/bluefield_config.go @@ -4,5 +4,6 @@ package api type BluefieldConfig struct { - BluefieldIP string `yaml:"bluefieldIP"` + BluefieldIP string `yaml:"bluefieldIP"` + LeaseTimes LeaseTimes `yaml:"leaseTimes"` } diff --git a/internal/api/leasetime_config.go b/internal/api/leasetime_config.go new file mode 100644 index 0000000..42b5465 --- /dev/null +++ b/internal/api/leasetime_config.go @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: MIT + +package api + +import ( + "fmt" + "time" +) + +// DefaultLeaseTime is the default preferred and valid lifetime for DHCP leases. +const DefaultLeaseTime = 24 * time.Hour + +// LeaseTimes holds the DHCPv6 preferred and valid lifetimes. Both are optional; +// when unset they default to DefaultLeaseTime (24h). +type LeaseTimes struct { + // PreferredLifetime is the time.Duration an address remains preferred. + // +optional + PreferredLifetime time.Duration `yaml:"preferredLifetime"` + // ValidLifetime is the time.Duration an address remains valid. + // +optional + ValidLifetime time.Duration `yaml:"validLifetime"` +} + +// Resolve returns the preferred and valid lifetimes, defaulting any zero value +// to DefaultLeaseTime. +func (l LeaseTimes) Resolve() (preferred, valid time.Duration) { + preferred = l.PreferredLifetime + if preferred == 0 { + preferred = DefaultLeaseTime + } + valid = l.ValidLifetime + if valid == 0 { + valid = DefaultLeaseTime + } + return preferred, valid +} + +// Validate checks the resolved lifetimes. Both must be positive, and per +// RFC 8415 the preferred lifetime must not exceed the valid lifetime. +func (l LeaseTimes) Validate() error { + preferred, valid := l.Resolve() + switch { + case preferred <= 0: + return fmt.Errorf("preferredLifetime must be positive, got %s", preferred) + case valid <= 0: + return fmt.Errorf("validLifetime must be positive, got %s", valid) + case preferred > valid: + return fmt.Errorf("preferredLifetime (%s) must not exceed validLifetime (%s)", preferred, valid) + } + return nil +} diff --git a/internal/api/onmetal_config.go b/internal/api/onmetal_config.go index cbb4aa2..b631079 100644 --- a/internal/api/onmetal_config.go +++ b/internal/api/onmetal_config.go @@ -9,4 +9,5 @@ type PrefixDelegation struct { type OnMetalConfig struct { PrefixDelegation PrefixDelegation `yaml:"prefixDelegation"` + LeaseTimes LeaseTimes `yaml:"leaseTimes"` } diff --git a/internal/api/oob_config.go b/internal/api/oob_config.go index f19be54..fbf61d9 100644 --- a/internal/api/oob_config.go +++ b/internal/api/oob_config.go @@ -11,4 +11,5 @@ type SubnetLabel struct { type OOBConfig struct { Namespace string `yaml:"namespace"` SubnetLabels []SubnetLabel `yaml:"subnetLabels"` + LeaseTimes LeaseTimes `yaml:"leaseTimes"` } diff --git a/internal/api/stateless_config.go b/internal/api/stateless_config.go new file mode 100644 index 0000000..661bdb2 --- /dev/null +++ b/internal/api/stateless_config.go @@ -0,0 +1,9 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: MIT + +package api + +// StatelessConfig holds the configuration for the stateless plugin. +type StatelessConfig struct { + LeaseTimes LeaseTimes `yaml:"leaseTimes"` +} diff --git a/plugins/bluefield/plugin.go b/plugins/bluefield/plugin.go index 9ad8822..c13ee42 100644 --- a/plugins/bluefield/plugin.go +++ b/plugins/bluefield/plugin.go @@ -27,7 +27,11 @@ var Plugin = plugins.Plugin{ Name: "bluefield", Setup6: setupPlugin, } -var ipaddr net.IP +var ( + ipaddr net.IP + preferredLifeTime time.Duration + validLifeTime time.Duration +) // args[0] = path to config file func parseArgs(args ...string) (string, error) { @@ -66,7 +70,11 @@ func setupPlugin(args ...string) (handler.Handler6, error) { if ipaddr == nil { return nil, fmt.Errorf("invalid IPv6 address: %s", args[0]) } - log.Infof("Parsed IP %s", ipaddr) + if err := bluefieldIPConfig.LeaseTimes.Validate(); err != nil { + return nil, fmt.Errorf("invalid lease times: %v", err) + } + preferredLifeTime, validLifeTime = bluefieldIPConfig.LeaseTimes.Resolve() + log.Infof("Parsed IP %s, lease times (preferred %s, valid %s)", ipaddr, preferredLifeTime, validLifeTime) return handleDHCPv6, nil } @@ -128,8 +136,8 @@ func addOptIANA(resp dhcpv6.DHCPv6, iaId [4]byte) { Options: dhcpv6.IdentityOptions{Options: []dhcpv6.Option{ &dhcpv6.OptIAAddress{ IPv6Addr: ipaddr, - PreferredLifetime: 24 * time.Hour, - ValidLifetime: 48 * time.Hour, + PreferredLifetime: preferredLifeTime, + ValidLifetime: validLifeTime, }, }}, }) diff --git a/plugins/bluefield/plugin_test.go b/plugins/bluefield/plugin_test.go index 773b49e..bc699e9 100644 --- a/plugins/bluefield/plugin_test.go +++ b/plugins/bluefield/plugin_test.go @@ -95,7 +95,7 @@ var _ = Describe("Bluefield Plugin", func() { Expect(respm.MessageType).To(Equal(dhcpv6.MessageTypeAdvertise)) Expect(respm.Options.OneIANA().Options.Options[0].(*dhcpv6.OptIAAddress).IPv6Addr.String()).To(Equal(testIP)) Expect(respm.Options.OneIANA().Options.Options[0].(*dhcpv6.OptIAAddress).PreferredLifetime).To(Equal(24 * time.Hour)) - Expect(respm.Options.OneIANA().Options.Options[0].(*dhcpv6.OptIAAddress).ValidLifetime).To(Equal(48 * time.Hour)) + Expect(respm.Options.OneIANA().Options.Options[0].(*dhcpv6.OptIAAddress).ValidLifetime).To(Equal(24 * time.Hour)) Expect(respm.Options.OneIANA().T1).To(Equal(1 * time.Hour)) Expect(respm.Options.OneIANA().T2).To(Equal(2 * time.Hour)) Expect(respm.Options.OneIANA().IaId).NotTo(BeNil()) @@ -112,7 +112,7 @@ var _ = Describe("Bluefield Plugin", func() { Expect(respm.MessageType).To(Equal(dhcpv6.MessageTypeReply)) Expect(respm.Options.OneIANA().Options.Options[0].(*dhcpv6.OptIAAddress).IPv6Addr.String()).To(Equal(testIP)) Expect(respm.Options.OneIANA().Options.Options[0].(*dhcpv6.OptIAAddress).PreferredLifetime).To(Equal(24 * time.Hour)) - Expect(respm.Options.OneIANA().Options.Options[0].(*dhcpv6.OptIAAddress).ValidLifetime).To(Equal(48 * time.Hour)) + Expect(respm.Options.OneIANA().Options.Options[0].(*dhcpv6.OptIAAddress).ValidLifetime).To(Equal(24 * time.Hour)) Expect(respm.Options.OneIANA().T1).To(Equal(1 * time.Hour)) Expect(respm.Options.OneIANA().T2).To(Equal(2 * time.Hour)) Expect(respm.Options.OneIANA().IaId).NotTo(BeNil()) @@ -129,7 +129,7 @@ var _ = Describe("Bluefield Plugin", func() { Expect(respm.MessageType).To(Equal(dhcpv6.MessageTypeReply)) Expect(respm.Options.OneIANA().Options.Options[0].(*dhcpv6.OptIAAddress).IPv6Addr.String()).To(Equal(testIP)) Expect(respm.Options.OneIANA().Options.Options[0].(*dhcpv6.OptIAAddress).PreferredLifetime).To(Equal(24 * time.Hour)) - Expect(respm.Options.OneIANA().Options.Options[0].(*dhcpv6.OptIAAddress).ValidLifetime).To(Equal(48 * time.Hour)) + Expect(respm.Options.OneIANA().Options.Options[0].(*dhcpv6.OptIAAddress).ValidLifetime).To(Equal(24 * time.Hour)) Expect(respm.Options.OneIANA().T1).To(Equal(1 * time.Hour)) Expect(respm.Options.OneIANA().T2).To(Equal(2 * time.Hour)) Expect(respm.Options.OneIANA().IaId).NotTo(BeNil()) @@ -159,7 +159,7 @@ var _ = Describe("Bluefield Plugin", func() { Expect(respm.MessageType).To(Equal(dhcpv6.MessageTypeReply)) Expect(respm.Options.OneIANA().Options.Options[0].(*dhcpv6.OptIAAddress).IPv6Addr.String()).To(Equal(testIP)) Expect(respm.Options.OneIANA().Options.Options[0].(*dhcpv6.OptIAAddress).PreferredLifetime).To(Equal(24 * time.Hour)) - Expect(respm.Options.OneIANA().Options.Options[0].(*dhcpv6.OptIAAddress).ValidLifetime).To(Equal(48 * time.Hour)) + Expect(respm.Options.OneIANA().Options.Options[0].(*dhcpv6.OptIAAddress).ValidLifetime).To(Equal(24 * time.Hour)) Expect(respm.Options.OneIANA().T1).To(Equal(1 * time.Hour)) Expect(respm.Options.OneIANA().T2).To(Equal(2 * time.Hour)) Expect(respm.Options.OneIANA().IaId).NotTo(BeNil()) diff --git a/plugins/onmetal/plugin.go b/plugins/onmetal/plugin.go index 19b0e4e..cd10fac 100644 --- a/plugins/onmetal/plugin.go +++ b/plugins/onmetal/plugin.go @@ -32,9 +32,12 @@ var Plugin = plugins.Plugin{ var prefixLength int +var ( + preferredLifeTime time.Duration + validLifeTime time.Duration +) + const ( - preferredLifeTime = 24 * time.Hour - validLifeTime = 24 * time.Hour prefixDelegationLengthMin = 1 prefixDelegationLengthMax = 127 ) @@ -77,6 +80,12 @@ func setup6(args ...string) (handler.Handler6, error) { return nil, fmt.Errorf("invalid prefix length: %d", prefixLength) } + if err := onMetalConfig.LeaseTimes.Validate(); err != nil { + return nil, fmt.Errorf("invalid lease times: %v", err) + } + preferredLifeTime, validLifeTime = onMetalConfig.LeaseTimes.Resolve() + log.Infof("Using lease times (preferred %s, valid %s)", preferredLifeTime, validLifeTime) + return handler6, nil } diff --git a/plugins/oob/plugin.go b/plugins/oob/plugin.go index a376ebf..1dfdb8b 100644 --- a/plugins/oob/plugin.go +++ b/plugins/oob/plugin.go @@ -40,10 +40,13 @@ var ( k8sClient *K8sClient ) +var ( + preferredLifeTime time.Duration + validLifeTime time.Duration +) + const ( - UNKNOWN_IP = "0.0.0.0" - preferredLifeTime = 24 * time.Hour - validLifeTime = 24 * time.Hour + UNKNOWN_IP = "0.0.0.0" ) // args[0] = path to config file @@ -85,6 +88,12 @@ func setup6(args ...string) (handler.Handler6, error) { return nil, fmt.Errorf("failed to create k8s client: %w", err) } + if err := oobConfig.LeaseTimes.Validate(); err != nil { + return nil, fmt.Errorf("invalid lease times: %v", err) + } + preferredLifeTime, validLifeTime = oobConfig.LeaseTimes.Resolve() + log.Infof("Using lease times (preferred %s, valid %s)", preferredLifeTime, validLifeTime) + log.Print("Loaded oob plugin for DHCPv6.") return handler6, nil } @@ -171,6 +180,12 @@ func setup4(args ...string) (handler.Handler4, error) { return nil, fmt.Errorf("failed to create k8s client: %w", err) } + if err := oobConfig.LeaseTimes.Validate(); err != nil { + return nil, fmt.Errorf("invalid lease times: %v", err) + } + preferredLifeTime, validLifeTime = oobConfig.LeaseTimes.Resolve() + log.Infof("Using lease times (preferred %s, valid %s)", preferredLifeTime, validLifeTime) + log.Print("Loaded oob plugin for DHCPv4.") return handler4, nil } diff --git a/plugins/stateless/plugin.go b/plugins/stateless/plugin.go index 53bc006..0948994 100644 --- a/plugins/stateless/plugin.go +++ b/plugins/stateless/plugin.go @@ -4,15 +4,19 @@ package stateless import ( + "fmt" "net" + "os" "time" "github.com/coredhcp/coredhcp/handler" "github.com/coredhcp/coredhcp/logger" "github.com/coredhcp/coredhcp/plugins" "github.com/insomniacslk/dhcp/dhcpv6" + "github.com/ironcore-dev/fedhcp/internal/api" "github.com/ironcore-dev/fedhcp/internal/helper" "github.com/ironcore-dev/fedhcp/internal/printer" + "gopkg.in/yaml.v3" ) var log = logger.GetLogger("plugins/stateless") @@ -23,13 +27,65 @@ var Plugin = plugins.Plugin{ } const ( - preferredLifeTime = 24 * time.Hour - validLifeTime = 24 * time.Hour - macLen = 6 - macOffset = 10 + macLen = 6 + macOffset = 10 ) -func setup6(_ ...string) (handler.Handler6, error) { +var ( + preferredLifeTime time.Duration + validLifeTime time.Duration +) + +// args[0] = path to config file (optional) +func parseArgs(args ...string) (string, error) { + if len(args) > 1 { + return "", fmt.Errorf("at most one argument may be passed to the plugin, got %d", len(args)) + } + if len(args) == 0 { + return "", nil + } + return args[0], nil +} + +func loadConfig(args ...string) error { + path, err := parseArgs(args...) + if err != nil { + return fmt.Errorf("invalid configuration: %v", err) + } + + // defaults + preferredLifeTime = api.DefaultLeaseTime + validLifeTime = api.DefaultLeaseTime + + if path == "" { + log.Infof("No config file provided, using default lease times (preferred %s, valid %s)", + preferredLifeTime, validLifeTime) + return nil + } + + log.Debugf("Reading config file %s", path) + configData, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("failed to read config file: %v", err) + } + + config := &api.StatelessConfig{} + if err = yaml.Unmarshal(configData, config); err != nil { + return fmt.Errorf("failed to parse config file: %v", err) + } + + if err := config.LeaseTimes.Validate(); err != nil { + return fmt.Errorf("invalid lease times: %v", err) + } + preferredLifeTime, validLifeTime = config.LeaseTimes.Resolve() + log.Infof("Using lease times (preferred %s, valid %s)", preferredLifeTime, validLifeTime) + return nil +} + +func setup6(args ...string) (handler.Handler6, error) { + if err := loadConfig(args...); err != nil { + return nil, err + } return handler6, nil } diff --git a/plugins/stateless/plugin_test.go b/plugins/stateless/plugin_test.go index 8753478..896f00a 100644 --- a/plugins/stateless/plugin_test.go +++ b/plugins/stateless/plugin_test.go @@ -5,10 +5,13 @@ package stateless import ( "net" + "os" "slices" "testing" + "time" "github.com/insomniacslk/dhcp/dhcpv6" + "github.com/ironcore-dev/fedhcp/internal/api" ) var expectedIAID = [4]byte{1, 2, 3, 4} @@ -25,6 +28,11 @@ func TestBuildAddressFromMAC(t *testing.T) { } func TestHandler6_PrefixLength80(t *testing.T) { + // resolve default lease times (no config file -> 24h/24h) + if err := loadConfig(); err != nil { + t.Fatalf("failed to load default config: %v", err) + } + req, err := dhcpv6.NewMessage() if err != nil { t.Fatal(err) @@ -71,11 +79,61 @@ func TestHandler6_PrefixLength80(t *testing.T) { preferred := iana.Options.Options[0].(*dhcpv6.OptIAAddress).PreferredLifetime valid := iana.Options.Options[0].(*dhcpv6.OptIAAddress).ValidLifetime - if preferred != preferredLifeTime { - t.Errorf("expected preferred lifetime %v, got %v", preferredLifeTime, preferred) + if preferred != api.DefaultLeaseTime { + t.Errorf("expected preferred lifetime %v, got %v", api.DefaultLeaseTime, preferred) + } + if valid != api.DefaultLeaseTime { + t.Errorf("expected valid lifetime %v, got %v", api.DefaultLeaseTime, valid) + } +} + +func TestHandler6_ConfigurableLeaseTimes(t *testing.T) { + dir := t.TempDir() + cfgPath := dir + "/stateless_config.yaml" + cfgData := []byte("leaseTimes:\n preferredLifetime: 1h\n validLifetime: 2h\n") + if err := os.WriteFile(cfgPath, cfgData, 0644); err != nil { + t.Fatal(err) + } + if err := loadConfig(cfgPath); err != nil { + t.Fatalf("failed to load config: %v", err) } - if valid != validLifeTime { - t.Errorf("expected valid lifetime %v, got %v", validLifeTime, valid) + if preferredLifeTime != time.Hour || validLifeTime != 2*time.Hour { + t.Fatalf("expected preferred 1h / valid 2h, got preferred %v / valid %v", preferredLifeTime, validLifeTime) + } +} + +func TestHandler6_RejectPreferredGreaterThanValid(t *testing.T) { + dir := t.TempDir() + cfgPath := dir + "/stateless_config.yaml" + // preferred (2h) exceeds valid (1h) -> must be rejected at setup + cfgData := []byte("leaseTimes:\n preferredLifetime: 2h\n validLifetime: 1h\n") + if err := os.WriteFile(cfgPath, cfgData, 0644); err != nil { + t.Fatal(err) + } + if err := loadConfig(cfgPath); err == nil { + t.Fatal("expected error for preferredLifetime > validLifetime, got nil") + } +} + +func TestHandler6_RejectNegativeLifetime(t *testing.T) { + for _, tc := range []struct { + name string + yaml string + }{ + {"negative preferred", "leaseTimes:\n preferredLifetime: -1h\n validLifetime: 1h\n"}, + {"negative valid", "leaseTimes:\n preferredLifetime: 1h\n validLifetime: -1h\n"}, + {"both negative", "leaseTimes:\n preferredLifetime: -1h\n validLifetime: -1h\n"}, + } { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + cfgPath := dir + "/stateless_config.yaml" + if err := os.WriteFile(cfgPath, []byte(tc.yaml), 0644); err != nil { + t.Fatal(err) + } + if err := loadConfig(cfgPath); err == nil { + t.Fatalf("expected error for %s, got nil", tc.name) + } + }) } }