Skip to content

Commit

Permalink
feat: add proxy DHCP server
Browse files Browse the repository at this point in the history
Implement proxy DHCP server which ehnahnces IPAM DHCP response with boot
information.

Signed-off-by: Gerard de Leeuw <[email protected]>
Signed-off-by: Andrey Smirnov <[email protected]>
  • Loading branch information
lion7 authored and smira committed Aug 24, 2023
1 parent baaece8 commit f88ee6d
Show file tree
Hide file tree
Showing 26 changed files with 447 additions and 445 deletions.
10 changes: 9 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ MODULE := $(shell head -1 go.mod | cut -d' ' -f2)

ARTIFACTS := _out
TEST_PKGS ?= ./...
TALOS_RELEASE ?= v1.5.0
TALOS_RELEASE ?= v1.6.0-alpha.0
DEFAULT_K8S_VERSION ?= v1.27.2

TOOLS ?= ghcr.io/siderolabs/tools:v1.5.0
Expand Down Expand Up @@ -227,3 +227,11 @@ conformance: ## Performs policy checks against the commit and source code.
.PHONY: clean
clean:
@rm -rf $(ARTIFACTS)

.PHONY: docs-preview
docs-preview: ## Starts a local preview of the documentation using Hugo in docker
@docker run --rm --interactive --tty \
--volume $(PWD):/src --workdir /src/website \
--publish 1313:1313 \
klakegg/hugo:0.95.0-ext-alpine \
server
16 changes: 16 additions & 0 deletions app/sidero-controller-manager/config/manager/manager.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
apiVersion: v1
kind: Service
metadata:
name: dhcp
namespace: system
spec:
ports:
- port: 67
targetPort: dhcp
protocol: UDP
selector:
control-plane: sidero-controller-manager
---
apiVersion: v1
kind: Service
metadata:
name: tftp
namespace: system
Expand Down Expand Up @@ -78,6 +91,9 @@ spec:
imagePullPolicy: Always
name: manager
ports:
- name: dhcp
containerPort: 67
protocol: UDP
- name: tftp
containerPort: 69
protocol: UDP
Expand Down
211 changes: 211 additions & 0 deletions app/sidero-controller-manager/internal/dhcp/dhcp_server.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.

package dhcp

import (
"errors"
"fmt"
"net"
"strconv"

"github.com/go-logr/logr"
"github.com/insomniacslk/dhcp/dhcpv4"
"github.com/insomniacslk/dhcp/dhcpv4/server4"
"github.com/insomniacslk/dhcp/iana"
"github.com/siderolabs/gen/slices"
)

// ServeDHCP starts the DHCP proxy server.
func ServeDHCP(logger logr.Logger, apiEndpoint string, apiPort int) error {
server, err := server4.NewServer(
"",
nil,
handlePacket(logger, apiEndpoint, apiPort),
)
if err != nil {
logger.Error(err, "error on DHCP4 proxy startup")

return err
}

return server.Serve()
}

func handlePacket(logger logr.Logger, apiEndpoint string, apiPort int) func(conn net.PacketConn, peer net.Addr, m *dhcpv4.DHCPv4) {
return func(conn net.PacketConn, peer net.Addr, m *dhcpv4.DHCPv4) {
if err := isBootDHCP(m); err != nil {
logger.Info("ignoring packet", "source", m.ClientHWAddr, "reason", err)

return
}

fwtype, err := validateDHCP(m)
if err != nil {
logger.Info("invalid packet", "source", m.ClientHWAddr, "reason", err)

return
}

resp, err := offerDHCP(m, apiEndpoint, apiPort, fwtype)
if err != nil {
logger.Error(err, "failed to construct ProxyDHCP offer", "source", m.ClientHWAddr)

return
}

logger.Info("offering boot response", "source", m.ClientHWAddr, "server", resp.TFTPServerName(), "boot_filename", resp.BootFileNameOption())

_, err = conn.WriteTo(resp.ToBytes(), peer)
if err != nil {
logger.Error(err, "failure sending response", "source", m.ClientHWAddr)
}
}
}

func isBootDHCP(pkt *dhcpv4.DHCPv4) error {
if pkt.MessageType() != dhcpv4.MessageTypeDiscover {
return fmt.Errorf("packet is %s, not %s", pkt.MessageType(), dhcpv4.MessageTypeDiscover)
}

if pkt.Options[93] == nil {
return errors.New("not a PXE boot request (missing option 93)")
}

return nil
}

func validateDHCP(m *dhcpv4.DHCPv4) (fwtype Firmware, err error) {
arches := m.ClientArch()

for _, arch := range arches {
switch arch { //nolint:exhaustive
case iana.INTEL_X86PC:
fwtype = FirmwareX86PC
case iana.EFI_IA32, iana.EFI_X86_64, iana.EFI_BC:
fwtype = FirmwareX86EFI
case iana.EFI_ARM64:
fwtype = FirmwareARMEFI
case iana.EFI_X86_HTTP, iana.EFI_X86_64_HTTP:
fwtype = FirmwareX86HTTP
case iana.EFI_ARM64_HTTP:
fwtype = FirmwareARMHTTP
}
}

if fwtype == FirmwareUnsupported {
return 0, fmt.Errorf("unsupported client arch: %v", slices.Map(arches, func(a iana.Arch) string { return a.String() }))
}

// Now, identify special sub-breeds of client firmware based on
// the user-class option. Note these only change the "firmware
// type", not the architecture we're reporting to Booters. We need
// to identify these as part of making the internal chainloading
// logic work properly.
if userClasses := m.UserClass(); len(userClasses) > 0 {
// If the client has had iPXE burned into its ROM (or is a VM
// that uses iPXE as the PXE "ROM"), special handling is
// needed because in this mode the client is using iPXE native
// drivers and chainloading to a UNDI stack won't work.
if userClasses[0] == "iPXE" && fwtype == FirmwareX86PC {
fwtype = FirmwareX86Ipxe
}
}

guid := m.GetOneOption(dhcpv4.OptionClientMachineIdentifier)
switch len(guid) {
case 0:
// A missing GUID is invalid according to the spec, however
// there are PXE ROMs in the wild that omit the GUID and still
// expect to boot. The only thing we do with the GUID is
// mirror it back to the client if it's there, so we might as
// well accept these buggy ROMs.
case 17:
if guid[0] != 0 {
return 0, errors.New("malformed client GUID (option 97), leading byte must be zero")
}
default:
return 0, errors.New("malformed client GUID (option 97), wrong size")
}

return fwtype, nil
}

func offerDHCP(req *dhcpv4.DHCPv4, apiEndpoint string, apiPort int, fwtype Firmware) (*dhcpv4.DHCPv4, error) {
serverIPs, err := net.LookupIP(apiEndpoint)
if err != nil {
return nil, err
}

if len(serverIPs) == 0 {
return nil, fmt.Errorf("no IPs found for %s", apiEndpoint)
}

// pick up the first address
serverIP := serverIPs[0]

modifiers := []dhcpv4.Modifier{
dhcpv4.WithServerIP(serverIP),
dhcpv4.WithOptionCopied(req, dhcpv4.OptionClientMachineIdentifier),
dhcpv4.WithOptionCopied(req, dhcpv4.OptionClassIdentifier),
}

resp, err := dhcpv4.NewReplyFromRequest(req,
modifiers...,
)
if err != nil {
return nil, err
}

if resp.GetOneOption(dhcpv4.OptionClassIdentifier) == nil {
resp.UpdateOption(dhcpv4.OptClassIdentifier("PXEClient"))
}

switch fwtype {
case FirmwareX86PC:
// This is completely standard PXE: just load a file from TFTP.
resp.UpdateOption(dhcpv4.OptTFTPServerName(serverIP.String()))
resp.UpdateOption(dhcpv4.OptBootFileName("undionly.kpxe"))
case FirmwareX86Ipxe:
// Almost standard PXE, but the boot filename needs to be a URL.
resp.UpdateOption(dhcpv4.OptBootFileName(fmt.Sprintf("tftp://%s/undionly.kpxe", serverIP)))
case FirmwareX86EFI:
// This is completely standard PXE: just load a file from TFTP.
resp.UpdateOption(dhcpv4.OptTFTPServerName(serverIP.String()))
resp.UpdateOption(dhcpv4.OptBootFileName("snp.efi"))
case FirmwareARMEFI:
// This is completely standard PXE: just load a file from TFTP.
resp.UpdateOption(dhcpv4.OptTFTPServerName(serverIP.String()))
resp.UpdateOption(dhcpv4.OptBootFileName("snp-arm64.efi"))
case FirmwareX86HTTP:
// This is completely standard HTTP-boot: just load a file from HTTP.
resp.UpdateOption(dhcpv4.OptBootFileName(fmt.Sprintf("http://%s/tftp/snp.ipxe", net.JoinHostPort(serverIP.String(), strconv.Itoa(apiPort)))))
case FirmwareARMHTTP:
// This is completely standard HTTP-boot: just load a file from HTTP.
resp.UpdateOption(dhcpv4.OptBootFileName(fmt.Sprintf("http://%s/tftp/snp-arm64.ipxe", net.JoinHostPort(serverIP.String(), strconv.Itoa(apiPort)))))
case FirmwareUnsupported:
fallthrough
default:
return nil, fmt.Errorf("unsupported firmware type %d", fwtype)
}

return resp, nil
}

// Firmware describes a kind of firmware attempting to boot.
//
// This should only be used for selecting the right bootloader,
// kernel selection should key off the more generic architecture.
type Firmware int

// The bootloaders that we know how to handle.
const (
FirmwareUnsupported Firmware = iota // Unsupported
FirmwareX86PC // "Classic" x86 BIOS with PXE/UNDI support
FirmwareX86EFI // EFI x86
FirmwareARMEFI // EFI ARM64
FirmwareX86Ipxe // "Classic" x86 BIOS running iPXE (no UNDI support)
FirmwareX86HTTP // HTTP Boot X86
FirmwareARMHTTP // ARM64 HTTP Boot
)
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,13 @@
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.

// Package vm provides tools to build a set of PXE-booted VMs for Sidero testing.
package vm
package dhcp_test

import "testing"

func TestEmpty(t *testing.T) {
// added for accurate coverage estimation
//
// please remove it once any unit-test is added
// for this package
}
25 changes: 15 additions & 10 deletions app/sidero-controller-manager/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import (
metalv1alpha1 "github.com/siderolabs/sidero/app/sidero-controller-manager/api/v1alpha1"
metalv1alpha2 "github.com/siderolabs/sidero/app/sidero-controller-manager/api/v1alpha2"
"github.com/siderolabs/sidero/app/sidero-controller-manager/controllers"
"github.com/siderolabs/sidero/app/sidero-controller-manager/internal/dhcp"
"github.com/siderolabs/sidero/app/sidero-controller-manager/internal/ipxe"
"github.com/siderolabs/sidero/app/sidero-controller-manager/internal/metadata"
"github.com/siderolabs/sidero/app/sidero-controller-manager/internal/power/api"
Expand Down Expand Up @@ -224,14 +225,22 @@ func main() {

errCh := make(chan error)

setupLog.Info("starting proxy DHCP server")

go func() {
if err := dhcp.ServeDHCP(ctrl.Log.WithName("dhcp-proxy"), apiEndpoint, apiPort); err != nil {
setupLog.Error(err, "unable to start proxy DHCP server", "controller", "Environment")
errCh <- err
}
}()

setupLog.Info("starting TFTP server")

go func() {
if err := tftp.ServeTFTP(); err != nil {
setupLog.Error(err, "unable to start TFTP server", "controller", "Environment")
errCh <- err
}

errCh <- err
}()

httpMux := http.NewServeMux()
Expand Down Expand Up @@ -282,12 +291,10 @@ func main() {
setupLog.Info("starting manager and HTTP server")

go func() {
err := mgr.Start(ctx)
if err != nil {
if err := mgr.Start(ctx); err != nil {
setupLog.Error(err, "problem running manager")
errCh <- err
}

errCh <- err
}()

go func() {
Expand All @@ -311,12 +318,10 @@ func main() {
httpMux.ServeHTTP(w, req)
})

err := http.ListenAndServe(fmt.Sprintf(":%d", httpPort), h2c.NewHandler(grpcHandler, h2s))
if err != nil {
if err := http.ListenAndServe(fmt.Sprintf(":%d", httpPort), h2c.NewHandler(grpcHandler, h2s)); err != nil {
setupLog.Error(err, "problem running HTTP server")
errCh <- err
}

errCh <- err
}()

for err = range errCh {
Expand Down
8 changes: 8 additions & 0 deletions hack/release.toml
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,12 @@ Sidero should be able to process machine config for future versions of Talos.
description = """\
Sidero Agent now runs DHCP client in the userland, on the link which was used to PXE boot the machine.
This allows to run Sidero Agent on the machine with several autoconfigured network interfaces, when one of them is used for the management network.
"""

[notes.dhcpproxy]
title = "DHCP Proxy"
description = """\
Sidero Controller Manager now includes DHCP proxy which augments DHCP response with additional PXE boot options.
When enabled, DHCP server in the environment only handles IP allocation and network configuration, while DHCP proxy
provides PXE boot information automatically based on the architecture and boot method.
"""
6 changes: 3 additions & 3 deletions sfyra/cmd/sfyra/cmd/bootstrap_capi.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,9 @@ var bootstrapCAPICmd = &cobra.Command{

RegistryMirrors: options.RegistryMirrors,

CPUs: options.BootstrapCPUs,
MemMB: options.BootstrapMemMB,
DiskGB: options.BootstrapDiskGB,
BootstrapCPUs: options.BootstrapCPUs,
BootstrapMemMB: options.BootstrapMemMB,
BootstrapDiskGB: options.BootstrapDiskGB,
})
if err != nil {
return err
Expand Down
6 changes: 3 additions & 3 deletions sfyra/cmd/sfyra/cmd/bootstrap_cluster.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,9 @@ var bootstrapClusterCmd = &cobra.Command{

RegistryMirrors: options.RegistryMirrors,

CPUs: options.BootstrapCPUs,
MemMB: options.BootstrapMemMB,
DiskGB: options.BootstrapDiskGB,
BootstrapCPUs: options.BootstrapCPUs,
BootstrapMemMB: options.BootstrapMemMB,
BootstrapDiskGB: options.BootstrapDiskGB,
})
if err != nil {
return err
Expand Down
Loading

0 comments on commit f88ee6d

Please sign in to comment.