From c2b755ac704f93c9825a956ab2236ac692338b94 Mon Sep 17 00:00:00 2001 From: Kapil Kumar Date: Thu, 16 Jan 2025 18:35:29 +0530 Subject: [PATCH] Implement new configuration strategy for `httpboot` plugin (#243) * Implement new configuration strategy for HTTPBOOT plugin * Fix complaince check * Add test configs dynamically * Update Readme.md * Refactor tests Fix some debugging output --------- Co-authored-by: kkumar Co-authored-by: Damyan Yordanov --- README.md | 9 +- config/default/config.yaml | 2 +- example/config.yaml | 2 +- example/httpboot_config.yaml | 2 + internal/api/httpboot_config.go | 9 ++ plugins/httpboot/plugin.go | 44 ++++-- plugins/httpboot/plugin_test.go | 245 ++++++++++++++++---------------- 7 files changed, 180 insertions(+), 133 deletions(-) create mode 100644 example/httpboot_config.yaml create mode 100644 internal/api/httpboot_config.go diff --git a/README.md b/README.md index ff176f4..9dbc003 100644 --- a/README.md +++ b/README.md @@ -30,9 +30,14 @@ Implements HTTP boot from [Unifed Kernel Image](https://uapi-group.org/specifica Delivers the HTTP boot image as a [BootFileURL](https://www.rfc-editor.org/rfc/rfc5970.html#section-3.2). Based on configuration it delivers either a client-specific UKIs dynamically or a default UKI for all clients. When client-specific UKIs are configured, IPv6 relays *must* be used, so the client can be identified based on its link-local address (which the relay always provides). ### Configuration -A single HTTP(s) URL shall be passed as a string. It must be either +A single HTTP(s) URL shall be passed as a string in `httpboot_config.yaml` as given below. It must be either - a direct URL to an UKI (default UKI for all clients) -- magic identifier `bootservice:`+ a URL to a boot service delivering dynamically client-specific UKIs based on client identification +- `clientSpecific: false` deliver default UKI for all clients via a static address +- `clientSpecific: true` use a boot service delivering dynamically client-specific UKIs, based on client identification +````yaml +bootFile: http://[2001:db8::1]/default-image.uki +clientSpecific: false +```` ### Notes - not tested on IPv4 - IPv6 relays are supported diff --git a/config/default/config.yaml b/config/default/config.yaml index 93b3564..3a25b2f 100644 --- a/config/default/config.yaml +++ b/config/default/config.yaml @@ -14,4 +14,4 @@ data: - onmetal: - dns: 2001:4860:4860::6464 2001:4860:4860::64 - pxeboot: pxeboot_config.yaml - - httpboot: bootservice:http://example.com/boot-operator-endpoint:8083 + - httpboot: httpboot_config.yaml diff --git a/example/config.yaml b/example/config.yaml index 742bcad..23aa90f 100644 --- a/example/config.yaml +++ b/example/config.yaml @@ -8,7 +8,7 @@ server6: # always provide the same IP address, no matter who's asking: # - bluefield: bluefield_config.yaml # implement HTTPBoot - - httpboot: http://[2001:db8::1]/image.uki + - httpboot: httpboot_config.yaml # add leased IPs to ironcore's IPAM - ipam: ipam_config.yaml # lease IPs based on /127 subnets coming from relays running on the switches diff --git a/example/httpboot_config.yaml b/example/httpboot_config.yaml new file mode 100644 index 0000000..98b4e6b --- /dev/null +++ b/example/httpboot_config.yaml @@ -0,0 +1,2 @@ +bootFile: http://[2001:db8::1]/default-image.uki +clientSpecific: false \ No newline at end of file diff --git a/internal/api/httpboot_config.go b/internal/api/httpboot_config.go new file mode 100644 index 0000000..c5cd0cf --- /dev/null +++ b/internal/api/httpboot_config.go @@ -0,0 +1,9 @@ +// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: MIT + +package api + +type HttpBootConfig struct { + BootFile string `yaml:"bootFile"` + ClientSpecific bool `yaml:"clientSpecific"` +} diff --git a/plugins/httpboot/plugin.go b/plugins/httpboot/plugin.go index bf621b1..940b3f2 100644 --- a/plugins/httpboot/plugin.go +++ b/plugins/httpboot/plugin.go @@ -10,6 +10,7 @@ import ( "io" "net/http" "net/url" + "os" "strings" "github.com/coredhcp/coredhcp/handler" @@ -17,6 +18,8 @@ import ( "github.com/coredhcp/coredhcp/plugins" "github.com/insomniacslk/dhcp/dhcpv4" "github.com/insomniacslk/dhcp/dhcpv6" + "github.com/ironcore-dev/fedhcp/internal/api" + "gopkg.in/yaml.v2" ) var bootFile4 string @@ -33,15 +36,38 @@ var Plugin = plugins.Plugin{ const httpClient = "HTTPClient" -func parseArgs(args ...string) (*url.URL, bool, error) { +func parseArgs(args ...string) (string, error) { if len(args) != 1 { - return nil, false, fmt.Errorf("exactly one argument must be passed to the httpboot plugin, got %d", len(args)) + return "", fmt.Errorf("exactly one argument must be passed to the plugin, got %d", len(args)) } - arg := args[0] - useBootService := strings.HasPrefix(arg, "bootservice:") - if useBootService { - arg = strings.TrimPrefix(arg, "bootservice:") + return args[0], nil +} + +func loadConfig(args ...string) (*api.HttpBootConfig, error) { + path, err := parseArgs(args...) + if err != nil { + return nil, fmt.Errorf("invalid configuration: %v", err) + } + + log.Debugf("Reading config file %s", path) + configData, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read config file: %v", err) + } + + config := &api.HttpBootConfig{} + if err = yaml.Unmarshal(configData, config); err != nil { + return nil, fmt.Errorf("failed to parse config file: %v", err) + } + return config, nil +} + +func parseConfig(args ...string) (*url.URL, bool, error) { + httpbootConfig, err := loadConfig(args...) + if err != nil { + return nil, false, fmt.Errorf("erorr loading plugin configuration: %v", err) } + arg := httpbootConfig.BootFile parsedURL, err := url.Parse(arg) if err != nil { return nil, false, fmt.Errorf("invalid URL: %v", err) @@ -49,11 +75,11 @@ func parseArgs(args ...string) (*url.URL, bool, error) { if (parsedURL.Scheme != "http" && parsedURL.Scheme != "https") || parsedURL.Host == "" || parsedURL.Path == "" { return nil, false, fmt.Errorf("malformed httpboot parameter, should be a valid HTTP(s) URL") } - return parsedURL, useBootService, nil + return parsedURL, httpbootConfig.ClientSpecific, nil } func setup6(args ...string) (handler.Handler6, error) { - u, ubs, err := parseArgs(args...) + u, ubs, err := parseConfig(args...) if err != nil { return nil, fmt.Errorf("invalid configuration: %v", err) } @@ -64,7 +90,7 @@ func setup6(args ...string) (handler.Handler6, error) { } func setup4(args ...string) (handler.Handler4, error) { - u, ubs, err := parseArgs(args...) + u, ubs, err := parseConfig(args...) if err != nil { return nil, fmt.Errorf("invalid configuration: %v", err) } diff --git a/plugins/httpboot/plugin_test.go b/plugins/httpboot/plugin_test.go index 53cee9b..649ed79 100644 --- a/plugins/httpboot/plugin_test.go +++ b/plugins/httpboot/plugin_test.go @@ -9,6 +9,7 @@ import ( "fmt" "net" "net/http" + "os" "strings" "testing" "time" @@ -16,6 +17,8 @@ import ( "github.com/insomniacslk/dhcp/dhcpv4" "github.com/insomniacslk/dhcp/dhcpv6" "github.com/insomniacslk/dhcp/iana" + "github.com/ironcore-dev/fedhcp/internal/api" + "gopkg.in/yaml.v2" ) const ( @@ -25,38 +28,89 @@ const ( expectedGenericBootURL = "https://[2001:db8::1]/boot.uki" expectedCustomBootURL = "https://[2001:db8::1]/client-specific/boot.uki" expectedDefaultCustomBootURL = "https://[2001:db8::1]/default.uki" - bootServiceEndpoint = "bootservice:http://[::1]:%d/httpboot" expectedEnterpriseNumber = 0 bootServicePort = 8888 + notKnownClientIP = "::2" + knownClientIP = "::1" + notKnownClientMAC = "11:22:33:44:55:66" + knownClientMAC = "aa:bb:cc:dd:ee:ff" ) var ( - expectedHTTPClient = []byte("HTTPClient") + expectedHTTPClient = []byte("HTTPClient") + tempConfigFilePattern = "*-httpboot_config.yaml" + genericConfig = &api.HttpBootConfig{ + BootFile: "https://[2001:db8::1]/boot.uki", + ClientSpecific: false, + } + + customConfig = &api.HttpBootConfig{ + BootFile: "http://[::1]:8888/httpboot", + ClientSpecific: true, + } ) -func Init4(bootURL string) { - _, err := setup4(bootURL) +func Init4(config api.HttpBootConfig, tempDir string) error { + configFile, err := createTempConfig(config, tempDir) + if err != nil { + return err + } + + _, err = setup4(configFile) + if err != nil { + return err + } + + return nil +} + +func Init6(config api.HttpBootConfig, tempDir string) error { + configFile, err := createTempConfig(config, tempDir) + if err != nil { + return err + } + + _, err = setup6(configFile) if err != nil { - log.Fatal(err) + return err } + + return nil } -func Init6(bootURL string) { - _, err := setup6(bootURL) +func createTempConfig(config api.HttpBootConfig, tempDir string) (string, error) { + configData, err := yaml.Marshal(config) + if err != nil { + return "", err + } + + file, err := os.CreateTemp(tempDir, tempConfigFilePattern) + if err != nil { + return "", err + } + defer func() { + _ = file.Close() + }() + + configFile := file.Name() + + err = os.WriteFile(configFile, configData, 0644) if err != nil { - log.Fatal(err) + return "", err } + + return configFile, nil } /* parametrization */ func TestWrongNumberArgs(t *testing.T) { - _, _, err := parseArgs("foo", "bar") + _, err := parseArgs("foo", "bar") if err == nil { t.Fatal("no error occurred when providing wrong number of args (2), but it should have") } - _, _, err = parseArgs() + _, err = parseArgs() if err == nil { t.Fatal("no error occurred when providing wrong number of args (0), but it should have") } @@ -65,16 +119,18 @@ func TestWrongNumberArgs(t *testing.T) { func TestWrongArgs(t *testing.T) { malformedBootURL := []string{"ftp://www.example.com/boot.uki", "tftp:/www.example.com/boot.uki", - "foobar:/www.example.com/boot.uki", - "bootfail:https://www.example.com/boot.uki", - "bootservice:tftp://www.example.com/boot.uki"} - + "foobar:/www.example.com/boot.uki"} for _, wrongURL := range malformedBootURL { - _, err := setup4(wrongURL) + malformedConfig := &api.HttpBootConfig{ + BootFile: wrongURL, + ClientSpecific: false, + } + tempDir := t.TempDir() + err := Init4(*malformedConfig, tempDir) if err == nil { t.Fatalf("no error occurred when parsing wrong boot param %s, but it should have", wrongURL) } - _, err = setup6(wrongURL) + err = Init6(*malformedConfig, tempDir) if err == nil { t.Fatalf("no error occurred when parsing wrong boot param %s, but it should have", wrongURL) } @@ -83,7 +139,8 @@ func TestWrongArgs(t *testing.T) { /* IPv6 */ func TestGenericHTTPBootRequested6(t *testing.T) { - Init6(expectedGenericBootURL) + tempDir := t.TempDir() + _ = Init6(*genericConfig, tempDir) req, err := dhcpv6.NewMessage() if err != nil { @@ -141,7 +198,8 @@ func TestGenericHTTPBootRequested6(t *testing.T) { } func TestMalformedHTTPBootRequested6(t *testing.T) { - Init6(expectedGenericBootURL) + tempDir := t.TempDir() + _ = Init6(*genericConfig, tempDir) req, err := dhcpv6.NewMessage() if err != nil { @@ -210,7 +268,8 @@ func TestMalformedHTTPBootRequested6(t *testing.T) { } func TestHTTPBootNotRequested6(t *testing.T) { - Init6(expectedGenericBootURL) + tempDir := t.TempDir() + _ = Init6(*genericConfig, tempDir) req, err := dhcpv6.NewMessage() if err != nil { @@ -251,7 +310,8 @@ func TestHTTPBootNotRequested6(t *testing.T) { } func TestHTTPBootNotRelayedMsg6(t *testing.T) { - Init6(expectedGenericBootURL) + tempDir := t.TempDir() + _ = Init6(*genericConfig, tempDir) req, err := dhcpv6.NewMessage() if err != nil { @@ -287,7 +347,8 @@ func TestHTTPBootNotRelayedMsg6(t *testing.T) { /* IPv4 */ func TestGenericHTTPBootRequested4(t *testing.T) { - Init4(expectedGenericBootURL) + tempDir := t.TempDir() + _ = Init4(*genericConfig, tempDir) req, err := dhcpv4.NewDiscovery(net.HardwareAddr{ 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff}, @@ -325,7 +386,8 @@ func TestGenericHTTPBootRequested4(t *testing.T) { } func TestMalformedHTTPBootRequested4(t *testing.T) { - Init4(expectedGenericBootURL) + tempDir := t.TempDir() + _ = Init4(*genericConfig, tempDir) req, err := dhcpv4.NewDiscovery(net.HardwareAddr{ 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff}, @@ -393,7 +455,8 @@ func TestMalformedHTTPBootRequested4(t *testing.T) { } func TestHTTPBootNotRequested4(t *testing.T) { - Init4(expectedGenericBootURL) + tempDir := t.TempDir() + _ = Init4(*genericConfig, tempDir) req, err := dhcpv4.NewDiscovery(net.HardwareAddr{ 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff}, @@ -434,83 +497,62 @@ func TestCustomHTTPBootRequestedKnownIP(t *testing.T) { go startBootServiceMock() time.Sleep(time.Second * 1) - Init6(fmt.Sprintf(bootServiceEndpoint, bootServicePort)) - req, err := dhcpv6.NewMessage() + ip := net.ParseIP(knownClientIP) + err, relayedRequest := createHTTPBootRequest(t, ip) if err != nil { t.Fatal(err) } - req.MessageType = dhcpv6.MessageTypeRequest - req.AddOption(dhcpv6.OptRequestedOption(dhcpv6.OptionBootfileURL)) - optVendorClass := dhcpv6.OptVendorClass{} - buf := []byte{ - 0, 0, 5, 57, // nice "random" enterprise number, can be ignored - 0, 10, // length ot vendor class - 'H', 'T', 'T', 'P', 'C', 'l', 'i', 'e', 'n', 't', // vendor class - } - _ = optVendorClass.FromBytes(buf) - req.UpdateOption(&optVendorClass) - // known LinkAddr - relayedRequest, err := dhcpv6.EncapsulateRelay(req, dhcpv6.MessageTypeRelayForward, net.IPv6loopback, net.IPv6loopback) + macAddress, _ := net.ParseMAC(notKnownClientMAC) + ensureBootURL(t, macAddress, relayedRequest, expectedCustomBootURL) +} + +func TestCustomHTTPBootRequestedKnownMAC(t *testing.T) { + ip := net.ParseIP(notKnownClientIP) + err, relayedRequest := createHTTPBootRequest(t, ip) if err != nil { t.Fatal(err) } - // not known LinkLayerAddress - macAddress, _ := net.ParseMAC("11:22:33:44:55:66") - opt := dhcpv6.OptClientLinkLayerAddress(iana.HWTypeEthernet, macAddress) - relayedRequest.AddOption(opt) + macAddress, _ := net.ParseMAC(knownClientMAC) + ensureBootURL(t, macAddress, relayedRequest, expectedCustomBootURL) +} - stub, err := dhcpv6.NewMessage() +func TestCustomHTTPBootRequestedUnknownClient(t *testing.T) { + ip := net.ParseIP(notKnownClientIP) + err, relayedRequest := createHTTPBootRequest(t, ip) if err != nil { t.Fatal(err) } - stub.MessageType = dhcpv6.MessageTypeReply - resp, stop := handler6(relayedRequest, stub) - if resp == nil { - t.Fatal("plugin did not return a message") - } - if stop { - t.Error("plugin interrupted processing, but it shouldn't have") - } - - opts := resp.GetOption(dhcpv6.OptionBootfileURL) - if len(opts) != optionEnabled { - t.Fatalf("Expected %d BootFileUrl option, got %d: %v", optionEnabled, len(opts), opts) - } - - bootFileURL := resp.(*dhcpv6.Message).Options.BootFileURL() - if bootFileURL != expectedCustomBootURL { - t.Errorf("Found BootFileURL %s, expected %s", bootFileURL, expectedCustomBootURL) - } + macAddress, _ := net.ParseMAC(notKnownClientMAC) + ensureBootURL(t, macAddress, relayedRequest, expectedDefaultCustomBootURL) +} - opts = resp.GetOption(dhcpv6.OptionVendorClass) - if len(opts) != optionEnabled { - t.Fatalf("Expected %d VendorClass option, got %d: %v", optionEnabled, len(opts), opts) - } +func createHTTPBootRequest(t *testing.T, clientIP net.IP) (error, *dhcpv6.RelayMessage) { + tempDir := t.TempDir() + _ = Init6(*customConfig, tempDir) - vc := resp.(*dhcpv6.Message).Options.VendorClasses()[0] - if vc.EnterpriseNumber != expectedEnterpriseNumber { - t.Errorf("Found EnterpriseNumber %d, expected %d", vc.EnterpriseNumber, expectedEnterpriseNumber) + req, err := dhcpv6.NewMessage() + if err != nil { + t.Fatal(err) } - - vcData := resp.(*dhcpv6.Message).Options.VendorClass(vc.EnterpriseNumber) - if !bytes.Equal(vcData[0], expectedHTTPClient) { - t.Errorf("Found VendorClass %x, expected %x", vcData[0], expectedHTTPClient) + req.MessageType = dhcpv6.MessageTypeRequest + req.AddOption(dhcpv6.OptRequestedOption(dhcpv6.OptionBootfileURL)) + optVendorClass := dhcpv6.OptVendorClass{} + buf := []byte{ + 0, 0, 5, 57, // nice "random" enterprise number, can be ignored + 0, 10, // length ot vendor class + 'H', 'T', 'T', 'P', 'C', 'l', 'i', 'e', 'n', 't', // vendor class } -} - -func TestCustomHTTPBootRequestedKnownMAC(t *testing.T) { - // known LinkLayerAddress - macAddress, _ := net.ParseMAC("aa:bb:cc:dd:ee:ff") + _ = optVendorClass.FromBytes(buf) + req.UpdateOption(&optVendorClass) - err, relayedRequest := createHTTPBootRequest(t) + relayedRequest, err := dhcpv6.EncapsulateRelay(req, dhcpv6.MessageTypeRelayForward, clientIP, net.IPv6loopback) if err != nil { t.Fatal(err) } - - ensureBootURL(t, macAddress, relayedRequest, expectedCustomBootURL) + return err, relayedRequest } func ensureBootURL(t *testing.T, macAddress net.HardwareAddr, relayedRequest *dhcpv6.RelayMessage, expectedBootURL string) { @@ -557,46 +599,9 @@ func ensureBootURL(t *testing.T, macAddress net.HardwareAddr, relayedRequest *dh } } -func TestCustomHTTPBootRequestedUnknownClient(t *testing.T) { - // not known LinkLayerAddress - macAddress, _ := net.ParseMAC("11:22:33:44:55:66") - - err, relayedRequest := createHTTPBootRequest(t) - if err != nil { - t.Fatal(err) - } - - ensureBootURL(t, macAddress, relayedRequest, expectedDefaultCustomBootURL) -} - -func createHTTPBootRequest(t *testing.T) (error, *dhcpv6.RelayMessage) { - Init6(fmt.Sprintf(bootServiceEndpoint, bootServicePort)) - req, err := dhcpv6.NewMessage() - if err != nil { - t.Fatal(err) - } - req.MessageType = dhcpv6.MessageTypeRequest - req.AddOption(dhcpv6.OptRequestedOption(dhcpv6.OptionBootfileURL)) - optVendorClass := dhcpv6.OptVendorClass{} - buf := []byte{ - 0, 0, 5, 57, // nice "random" enterprise number, can be ignored - 0, 10, // length ot vendor class - 'H', 'T', 'T', 'P', 'C', 'l', 'i', 'e', 'n', 't', // vendor class - } - _ = optVendorClass.FromBytes(buf) - req.UpdateOption(&optVendorClass) - - // not known LinkAddr - relayedRequest, err := dhcpv6.EncapsulateRelay(req, dhcpv6.MessageTypeRelayForward, - net.IP{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2}, net.IPv6loopback) - if err != nil { - t.Fatal(err) - } - return err, relayedRequest -} - func TestNoRelayCustomHTTPBootRequested(t *testing.T) { - Init6(fmt.Sprintf(bootServiceEndpoint, bootServicePort)) + tempDir := t.TempDir() + _ = Init6(*customConfig, tempDir) req, err := dhcpv6.NewMessage() if err != nil { @@ -662,12 +667,12 @@ func httpHandler(w http.ResponseWriter, r *http.Request) { } switch clientIP { - case "::1": + case knownClientIP: log.Printf("Match for client IP '%s' found", clientIP) httpBootResponseData["ClientIPs"] = strings.Join(clientIPs, ", ") httpBootResponseData["UKIURL"] = expectedCustomBootURL goon = false - case "aa:bb:cc:dd:ee:ff": + case knownClientMAC: log.Printf("Match for client MAC '%s' found", clientIP) httpBootResponseData["ClientIPs"] = strings.Join(clientIPs, ", ") httpBootResponseData["UKIURL"] = expectedCustomBootURL