diff --git a/README.md b/README.md index d2f1001b..543ce26c 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,10 @@ Smee's DHCP functionality can operate in one of the following modes: Smee will not respond to DHCP requests from clients. This is useful when the network has an existing DHCP server that will provide both IP and next boot info and Smee's TFTP and HTTP functionality will be used. The IP address in the hardware record must be the same as the IP address of the client requesting the `auto.ipxe` script. See this [doc](docs/DHCP.md) for more details. To enable this mode set `-dhcp-enabled=false`. +### Auto discovery + +In proxy DHCP mode, Smee can optionally be configured to provide network boot options to all network boot clients. This is called auto-discovery or auto-enrollment. When set to true, all machines are treated as iPXE clients and boot from the network, and a new hardware entry is automatically created for each when Smee receives a DHCP request. This feature is useful for environments where hardware is frequently added or removed, as it allows for automatic tracking and management of hardware resources. + ### Interoperability with other DHCP servers It is not recommended, but it is possible for Smee to be run in `reservation` mode in networks with another DHCP server(s). To get the intended behavior from Smee one of the following must be true. diff --git a/cmd/smee/flag.go b/cmd/smee/flag.go index 2541158d..ad47845e 100644 --- a/cmd/smee/flag.go +++ b/cmd/smee/flag.go @@ -113,6 +113,7 @@ func ipxeHTTPScriptFlags(c *config, fs *flag.FlagSet) { func dhcpFlags(c *config, fs *flag.FlagSet) { fs.BoolVar(&c.dhcp.enabled, "dhcp-enabled", true, "[dhcp] enable DHCP server") fs.StringVar(&c.dhcp.mode, "dhcp-mode", "reservation", "[dhcp] DHCP mode (reservation, proxy)") + fs.BoolVar(&c.dhcp.autoDiscovery, "dhcp-auto-discovery", false, "[dhcp] enable auto discovery mode (only available with -dhcp-mode=proxy)") fs.StringVar(&c.dhcp.bindAddr, "dhcp-addr", "0.0.0.0:67", "[dhcp] local IP:Port to listen on for DHCP requests") fs.StringVar(&c.dhcp.bindInterface, "dhcp-iface", "", "[dhcp] interface to bind to for DHCP requests") fs.StringVar(&c.dhcp.ipForPacket, "dhcp-ip-for-packet", detectPublicIPv4(""), "[dhcp] IP address to use in DHCP packets (opt 54, etc)") diff --git a/cmd/smee/main.go b/cmd/smee/main.go index 2e7b6253..183eed2e 100644 --- a/cmd/smee/main.go +++ b/cmd/smee/main.go @@ -90,6 +90,7 @@ type ipxeHTTPScript struct { type dhcpConfig struct { enabled bool mode string + autoDiscovery bool bindAddr string bindInterface string ipForPacket string @@ -344,7 +345,7 @@ func (c *config) dhcpHandler(ctx context.Context, log logr.Logger) (server.Handl return dh, nil case "proxy": dh := &proxy.Handler{ - Backend: backend, + Backend: backend.(handler.BackendReadWriter), IPAddr: pktIP, Log: log, Netboot: proxy.Netboot{ @@ -353,7 +354,8 @@ func (c *config) dhcpHandler(ctx context.Context, log logr.Logger) (server.Handl IPXEScriptURL: ipxeScript, Enabled: true, }, - OTELEnabled: true, + OTELEnabled: true, + AutoDiscoveryEnabled: c.dhcp.autoDiscovery, } return dh, nil } diff --git a/internal/backend/kube/error.go b/internal/backend/kube/error.go index 4bd8b1e7..243e9673 100644 --- a/internal/backend/kube/error.go +++ b/internal/backend/kube/error.go @@ -5,3 +5,11 @@ type hardwareNotFoundError struct{} func (hardwareNotFoundError) NotFound() bool { return true } func (hardwareNotFoundError) Error() string { return "hardware not found" } + +func IsHardwareNotFoundError(err error) bool { + if err == nil { + return false + } + e, ok := err.(hardwareNotFoundError) + return ok && e.NotFound() +} diff --git a/internal/backend/kube/kube.go b/internal/backend/kube/kube.go index d0210b7d..463f566c 100644 --- a/internal/backend/kube/kube.go +++ b/internal/backend/kube/kube.go @@ -8,19 +8,23 @@ import ( "net" "net/netip" "net/url" + "strings" "github.com/tinkerbell/smee/internal/dhcp/data" "github.com/tinkerbell/tink/api/v1alpha1" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/codes" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/rest" + "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/cluster" ) const tracerName = "github.com/tinkerbell/smee/dhcp" +const tinkerbellNamespace = "tink-system" // Backend is a backend implementation that uses the Tinkerbell CRDs to get DHCP data. type Backend struct { @@ -182,6 +186,42 @@ func (b *Backend) GetByIP(ctx context.Context, ip net.IP) (*data.DHCP, *data.Net return d, n, nil } +func (b *Backend) CreateByMac(ctx context.Context, mac net.HardwareAddr) error { + tracer := otel.Tracer(tracerName) + ctx, span := tracer.Start(ctx, "backend.kube.CreateByMac") + defer span.End() + + hardware := &v1alpha1.Hardware{ + ObjectMeta: metav1.ObjectMeta{ + Name: strings.Replace(mac.String(), ":", "-", -1), + Namespace: tinkerbellNamespace, + Labels: map[string]string{ + "created-by": "autodiscovery", + }, + }, + Spec: v1alpha1.HardwareSpec{ + Interfaces: []v1alpha1.Interface{ + { + DHCP: &v1alpha1.DHCP{ + MAC: mac.String(), + }, + Netboot: &v1alpha1.Netboot{ + AllowPXE: ptr.To[bool](true), + AllowWorkflow: ptr.To[bool](true), + }, + }, + }, + }, + } + + if err := b.cluster.GetClient().Create(ctx, hardware); err != nil { + span.SetStatus(codes.Error, err.Error()) + return fmt.Errorf("failed to create hardware for (%v): %w", mac, err) + } + + return nil +} + // toDHCPData converts a v1alpha1.DHCP to a data.DHCP data structure. // if required fields are missing, an error is returned. // Required fields: v1alpha1.Interface.DHCP.MAC, v1alpha1.Interface.DHCP.IP.Address, v1alpha1.Interface.DHCP.IP.Netmask. diff --git a/internal/dhcp/handler/handler.go b/internal/dhcp/handler/handler.go index 2cc34fbe..978570ec 100644 --- a/internal/dhcp/handler/handler.go +++ b/internal/dhcp/handler/handler.go @@ -17,3 +17,15 @@ type BackendReader interface { GetByMac(context.Context, net.HardwareAddr) (*data.DHCP, *data.Netboot, error) GetByIP(context.Context, net.IP) (*data.DHCP, *data.Netboot, error) } + +// BackendWriter is the interface for writing data to a backend. +type BackendWriter interface { + // Write data (to a backend) based on a mac address for creating a new hardware. + CreateByMac(context.Context, net.HardwareAddr) error +} + +// BackendReadWriter is the interface for getting and setting data from a backend. +type BackendReadWriter interface { + BackendReader + BackendWriter +} diff --git a/internal/dhcp/handler/proxy/proxy.go b/internal/dhcp/handler/proxy/proxy.go index 0de6f559..ce59281b 100644 --- a/internal/dhcp/handler/proxy/proxy.go +++ b/internal/dhcp/handler/proxy/proxy.go @@ -23,6 +23,7 @@ import ( "github.com/go-logr/logr" "github.com/insomniacslk/dhcp/dhcpv4" + "github.com/tinkerbell/smee/internal/backend/kube" "github.com/tinkerbell/smee/internal/dhcp" "github.com/tinkerbell/smee/internal/dhcp/data" "github.com/tinkerbell/smee/internal/dhcp/handler" @@ -39,7 +40,7 @@ const tracerName = "github.com/tinkerbell/smee/internal/dhcp/handler/proxy" // Handler holds the configuration details for the running the DHCP server. type Handler struct { // Backend is the backend to use for getting DHCP data. - Backend handler.BackendReader + Backend handler.BackendReadWriter // IPAddr is the IP address to use in DHCP responses. // Option 54 and the sname DHCP header. @@ -58,6 +59,9 @@ type Handler struct { // For example, the filename will be "snp.efi-00-23b1e307bb35484f535a1f772c06910e-d887dc3912240434-01". // -00--- OTELEnabled bool + + // AutoDiscoveryEnabled is a flag that determines whether a new hardware entry should be created upon receiving a DHCP request. + AutoDiscoveryEnabled bool } // Netboot holds the netboot configuration details used in running a DHCP server. @@ -184,12 +188,16 @@ func (h *Handler) Handle(ctx context.Context, conn *ipv4.PacketConn, dp data.Pac // set bootfile header reply.BootFileName = i.Bootfile("", h.Netboot.IPXEScriptURL(dp.Pkt), h.Netboot.IPXEBinServerHTTP, h.Netboot.IPXEBinServerTFTP) + var notHardwareFound bool // check the backend, if PXE is NOT allowed, set the boot file name to "//not-allowed" _, n, err := h.Backend.GetByMac(ctx, dp.Pkt.ClientHWAddr) - if err != nil || (n != nil && !n.AllowNetboot) { - log.V(1).Info("Ignoring packet", "error", err.Error(), "netbootAllowed", n.AllowNetboot) - span.SetStatus(codes.Ok, "netboot not allowed") - return + if err != nil { + notHardwareFound = kube.IsHardwareNotFoundError(err) + if n != nil && !n.AllowNetboot { + log.V(1).Info("Ignoring packet", "error", err.Error(), "netbootAllowed", n.AllowNetboot) + span.SetStatus(codes.Ok, "netboot not allowed") + return + } } log.Info( "received DHCP packet", @@ -220,6 +228,16 @@ func (h *Handler) Handle(ctx context.Context, conn *ipv4.PacketConn, dp data.Pac log.Info("Sent ProxyDHCP response") span.SetAttributes(h.encodeToAttributes(reply, "reply")...) span.SetStatus(codes.Ok, "sent DHCP response") + + // Create a new hardware entry if AutoDiscoveryEnabled is set to true and a hardware entry does not already exist. + if notHardwareFound && h.AutoDiscoveryEnabled && reply.MessageType() == dhcpv4.MessageTypeAck { + log.Info("AutoDiscoveryEnabled - Creating a new hardware entry", "mac", dp.Pkt.ClientHWAddr.String()) + if err := h.Backend.CreateByMac(ctx, dp.Pkt.ClientHWAddr); err != nil { + log.Error(err, "failed to create hardware entry") + span.SetStatus(codes.Error, err.Error()) + return + } + } } // encodeToAttributes takes a DHCP packet and returns opentelemetry key/value attributes. diff --git a/manifests/kustomize/base/rbac.yaml b/manifests/kustomize/base/rbac.yaml index bf38f6be..bf899072 100644 --- a/manifests/kustomize/base/rbac.yaml +++ b/manifests/kustomize/base/rbac.yaml @@ -19,6 +19,7 @@ rules: - get - list - watch + - create - apiGroups: - tinkerbell.org resources: