Skip to content

Commit

Permalink
Merge pull request #387 from jacobweinstock/proxydhcp
Browse files Browse the repository at this point in the history
WIP: Add proxyDHCP handler

## Description

<!--- Please describe what this PR is going to change -->
This add proxyDHCP support/handler. With the addition of a second handler a lot of code was moved around so that it could be used by both handlers.

With ProxyDHCP enabled Smee will respond to PXE enabled DHCP requests from clients and provide them with next boot info when netbooting. To enable this mode set `-dhcp-mode=proxy`.

Definition: 
[A] Proxy DHCP server behaves much like a DHCP server by listening for ordinary DHCP client traffic and responding to certain client requests. However, unlike the DHCP server, the PXE Proxy DHCP server does not administer network addresses, and it only responds to clients that identify themselves as PXE clients. The responses given by the PXE Proxy DHCP server contain the mechanism by which the client locates the boot servers or the network addresses and descriptions of the supported, compatible boot servers." -- [IBM](https://www.ibm.com/docs/en/aix/7.1?topic=protocol-preboot-execution-environment-proxy-dhcp-daemon)

## Why is this needed

<!--- Link to issue you have raised -->

Fixes: #

## How Has This Been Tested?
<!--- Please describe in detail how you tested your changes. -->
<!--- Include details of your testing environment, and the tests you ran to -->
<!--- see how your change affects other areas of the code, etc. -->
Manually tested.

## How are existing users impacted? What migration steps/scripts do we need?

<!--- Fixes a bug, unblocks installation, removes a component of the stack etc -->
<!--- Requires a DB migration script, etc. -->


## Checklist:

I have:

- [ ] updated the documentation and/or roadmap (if required)
- [ ] added unit or e2e tests
- [ ] provided instructions on how to upgrade
  • Loading branch information
jacobweinstock authored Jan 15, 2024
2 parents a416dd9 + 4eaa8be commit 437581c
Show file tree
Hide file tree
Showing 50 changed files with 6,627 additions and 497 deletions.
30 changes: 26 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Smee is the network boot service in the [Tinkerbell stack](https://tinkerbell.or
- backend support
- Kubernetes
- file based
- ProxyDHCP support
- TFTP server
- serving iPXE binaries
- HTTP server
Expand All @@ -31,13 +32,32 @@ A fixed IP address that is reserved for a specific client.
An IP address, that can potentially change, that is assigned to a client by the DHCP server.
The IP is typically pulled from a pool or subnet of available IP addresses.

**ProxyDHCP:**
"[A] Proxy DHCP server behaves much like a DHCP server by listening for ordinary DHCP client traffic and responding to certain client requests. However, unlike the DHCP server, the PXE Proxy DHCP server does not administer network addresses, and it only responds to clients that identify themselves as PXE clients.
The responses given by the PXE Proxy DHCP server contain the mechanism by which the client locates the boot servers or the network addresses and descriptions of the supported, compatible boot servers."
-- [IBM](https://www.ibm.com/docs/en/aix/7.1?topic=protocol-preboot-execution-environment-proxy-dhcp-daemon)

## Running Smee

The DHCP server of Smee serves explicit host reservations only. This means that only hosts that are configured will be served an IP address and network boot details.
### DHCP Modes

Smee's DHCP functionality can operate in one of the following modes:

1. **DHCP Reservation**
Smee will respond to DHCP requests from clients and provide them with IP and next boot info when netbooting. This is the default mode. IP info is all reservation based. There must be a corresponding hardware record for the requesting client's MAC address.
To enable this mode set `-dhcp-mode=reservation`.

1. **Proxy DHCP**
Smee will respond to PXE enabled DHCP requests from clients and provide them with next boot info when netbooting. In this mode an existing DHCP server that does not serve network boot information is required. Smee will respond to PXE enabled DHCP requests and provide the client with the next boot info. There must be a corresponding hardware record for the requesting client's MAC address. The `auto.ipxe` script will be served with the MAC address in the URL and the MAC address will be used to lookup the corresponding hardware record. Layer 2 access to machines or a DHCP relay agent that will forward the DHCP requests to Smee is required.
To enable this mode set `-dhcp-mode=proxy`.

1. **DHCP disabled**
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`.

## Interoperability with other DHCP servers
### Interoperability with other DHCP servers

It is not recommended, but it is possible for Smee to be run in networks with another DHCP server(s). To get the intended behavior from Smee one of the following must be true.
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.

1. All DHCP servers are configured to serve the same IPAM info as Smee and Smee is the only DHCP server to provide network boot info.

Expand Down Expand Up @@ -82,8 +102,10 @@ FLAGS
-dhcp-http-ipxe-script-url [dhcp] HTTP iPXE script URL to use in DHCP packets (default "http://172.17.0.2/auto.ipxe")
-dhcp-iface [dhcp] interface to bind to for DHCP requests
-dhcp-ip-for-packet [dhcp] IP address to use in DHCP packets (opt 54, etc) (default "172.17.0.2")
-dhcp-mode [dhcp] DHCP mode (reservation, proxy) (default "reservation")
-dhcp-syslog-ip [dhcp] Syslog server IP address to use in DHCP packets (opt 7) (default "172.17.0.2")
-dhcp-tftp-ip [dhcp] TFTP server IP address to use in DHCP packets (opt 66, etc) (default "172.17.0.2:69")
-disable-discover-trusted-proxies [http] disable discovery of trusted proxies from Kubernetes, only available for the Kubernetes backend (default "false")
-extra-kernel-args [http] extra set of kernel args (k=v k=v) that are appended to the kernel cmdline iPXE script
-http-addr [http] local IP:Port to listen on for iPXE HTTP script requests (default "172.17.0.2:80")
-http-ipxe-binary-enabled [http] enable iPXE HTTP binary server (default "true")
Expand Down Expand Up @@ -115,7 +137,7 @@ docker compose up --build # build images and start the network & services
docker compose down # stop the network & containers
```
Alternatively you can manually run Smee by itself. It requires a few
Alternatively Smee can be run by itself. It requires a few
flags or environment variables for configuration.
`test/hardware.yaml` should be safe enough for most developers to
Expand Down
331 changes: 331 additions & 0 deletions backend/file/file.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,331 @@
// Package file watches a file for changes and updates the in memory DHCP data.
package file

import (
"context"
"fmt"
"net"
"net/netip"
"net/url"
"os"
"path/filepath"
"strings"
"sync"

"github.com/fsnotify/fsnotify"
"github.com/ghodss/yaml"
"github.com/go-logr/logr"
"github.com/tinkerbell/smee/dhcp/data"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/codes"
)

const tracerName = "github.com/tinkerbell/smee/dhcp"

// Errors used by the file watcher.
var (
// errFileFormat is returned when the file is not in the correct format, e.g. not valid YAML.
errFileFormat = fmt.Errorf("invalid file format")
errRecordNotFound = fmt.Errorf("record not found")
errParseIP = fmt.Errorf("failed to parse IP from File")
errParseSubnet = fmt.Errorf("failed to parse subnet mask from File")
errParseURL = fmt.Errorf("failed to parse URL")
)

// netboot is the structure for the data expected in a file.
type netboot struct {
AllowPXE bool `yaml:"allowPxe"` // If true, the client will be provided netboot options in the DHCP offer/ack.
IPXEScriptURL string `yaml:"ipxeScriptUrl"` // Overrides default value of that is passed into DHCP on startup.
IPXEScript string `yaml:"ipxeScript"` // Overrides a default value that is passed into DHCP on startup.
Console string `yaml:"console"`
Facility string `yaml:"facility"`
}

// dhcp is the structure for the data expected in a file.
type dhcp struct {
MACAddress net.HardwareAddr // The MAC address of the client.
IPAddress string `yaml:"ipAddress"` // yiaddr DHCP header.
SubnetMask string `yaml:"subnetMask"` // DHCP option 1.
DefaultGateway string `yaml:"defaultGateway"` // DHCP option 3.
NameServers []string `yaml:"nameServers"` // DHCP option 6.
Hostname string `yaml:"hostname"` // DHCP option 12.
DomainName string `yaml:"domainName"` // DHCP option 15.
BroadcastAddress string `yaml:"broadcastAddress"` // DHCP option 28.
NTPServers []string `yaml:"ntpServers"` // DHCP option 42.
VLANID string `yaml:"vlanID"` // DHCP option 43.116.
LeaseTime int `yaml:"leaseTime"` // DHCP option 51.
Arch string `yaml:"arch"` // DHCP option 93.
DomainSearch []string `yaml:"domainSearch"` // DHCP option 119.
Netboot netboot `yaml:"netboot"`
}

// Watcher represents the backend for watching a file for changes and updating the in memory DHCP data.
type Watcher struct {
fileMu sync.RWMutex // protects FilePath for reads

// FilePath is the path to the file to watch.
FilePath string

// Log is the logger to be used in the File backend.
Log logr.Logger
dataMu sync.RWMutex // protects data
data []byte // data from file
watcher *fsnotify.Watcher
}

// NewWatcher creates a new file watcher.
func NewWatcher(l logr.Logger, f string) (*Watcher, error) {
watcher, err := fsnotify.NewWatcher()
if err != nil {
return nil, err
}
if err := watcher.Add(f); err != nil {
return nil, err
}

w := &Watcher{
FilePath: f,
watcher: watcher,
Log: l,
}

w.fileMu.RLock()
w.data, err = os.ReadFile(filepath.Clean(f))
w.fileMu.RUnlock()
if err != nil {
return nil, err
}

return w, nil
}

// GetByMac is the implementation of the Backend interface.
// It reads a given file from the in memory data (w.data).
func (w *Watcher) GetByMac(ctx context.Context, mac net.HardwareAddr) (*data.DHCP, *data.Netboot, error) {
tracer := otel.Tracer(tracerName)
_, span := tracer.Start(ctx, "backend.file.GetByMac")
defer span.End()

// get data from file, translate it, then pass it into setDHCPOpts and setNetworkBootOpts
w.dataMu.RLock()
d := w.data
w.dataMu.RUnlock()
r := make(map[string]dhcp)
if err := yaml.Unmarshal(d, &r); err != nil {
err := fmt.Errorf("%w: %w", err, errFileFormat)
w.Log.Error(err, "failed to unmarshal file data")
span.SetStatus(codes.Error, err.Error())

return nil, nil, err
}
for k, v := range r {
if strings.EqualFold(k, mac.String()) {
// found a record for this mac address
v.MACAddress = mac
d, n, err := w.translate(v)
if err != nil {
span.SetStatus(codes.Error, err.Error())

return nil, nil, err
}
span.SetAttributes(d.EncodeToAttributes()...)
span.SetAttributes(n.EncodeToAttributes()...)
span.SetStatus(codes.Ok, "")

return d, n, nil
}
}

err := fmt.Errorf("%w: %s", errRecordNotFound, mac.String())
span.SetStatus(codes.Error, err.Error())

return nil, nil, err
}

// GetByIP is the implementation of the Backend interface.
// It reads a given file from the in memory data (w.data).
func (w *Watcher) GetByIP(ctx context.Context, ip net.IP) (*data.DHCP, *data.Netboot, error) {
tracer := otel.Tracer(tracerName)
_, span := tracer.Start(ctx, "backend.file.GetByIP")
defer span.End()

// get data from file, translate it, then pass it into setDHCPOpts and setNetworkBootOpts
w.dataMu.RLock()
d := w.data
w.dataMu.RUnlock()
r := make(map[string]dhcp)
if err := yaml.Unmarshal(d, &r); err != nil {
err := fmt.Errorf("%w: %w", err, errFileFormat)
w.Log.Error(err, "failed to unmarshal file data")
span.SetStatus(codes.Error, err.Error())

return nil, nil, err
}
for k, v := range r {
if v.IPAddress == ip.String() {
// found a record for this ip address
v.IPAddress = ip.String()
mac, err := net.ParseMAC(k)
if err != nil {
err := fmt.Errorf("%w: %w", err, errFileFormat)
w.Log.Error(err, "failed to parse mac address")
span.SetStatus(codes.Error, err.Error())

return nil, nil, err
}
v.MACAddress = mac
d, n, err := w.translate(v)
if err != nil {
span.SetStatus(codes.Error, err.Error())

return nil, nil, err
}
span.SetAttributes(d.EncodeToAttributes()...)
span.SetAttributes(n.EncodeToAttributes()...)
span.SetStatus(codes.Ok, "")

return d, n, nil
}
}

err := fmt.Errorf("%w: %s", errRecordNotFound, ip.String())
span.SetStatus(codes.Error, err.Error())

return nil, nil, err
}

// Start starts watching a file for changes and updates the in memory data (w.data) on changes.
// Start is a blocking method. Use a context cancellation to exit.
func (w *Watcher) Start(ctx context.Context) {
for {
select {
case <-ctx.Done():
w.Log.Info("stopping watcher")
return
case event, ok := <-w.watcher.Events:
if !ok {
continue
}
if event.Op&fsnotify.Write == fsnotify.Write {
w.Log.Info("file changed, updating cache")
w.fileMu.RLock()
d, err := os.ReadFile(w.FilePath)
w.fileMu.RUnlock()
if err != nil {
w.Log.Error(err, "failed to read file", "file", w.FilePath)
break
}
w.dataMu.Lock()
w.data = d
w.dataMu.Unlock()
}
case err, ok := <-w.watcher.Errors:
if !ok {
continue
}
w.Log.Info("error watching file", "err", err)
}
}
}

// translate converts the data from the file into a data.DHCP and data.Netboot structs.
func (w *Watcher) translate(r dhcp) (*data.DHCP, *data.Netboot, error) {
d := new(data.DHCP)
n := new(data.Netboot)

d.MACAddress = r.MACAddress
// ip address, required
ip, err := netip.ParseAddr(r.IPAddress)
if err != nil {
return nil, nil, fmt.Errorf("%w: %w", err, errParseIP)
}
d.IPAddress = ip

// subnet mask, required
sm := net.ParseIP(r.SubnetMask)
if sm == nil {
return nil, nil, errParseSubnet
}
d.SubnetMask = net.IPMask(sm.To4())

// default gateway, optional
if dg, err := netip.ParseAddr(r.DefaultGateway); err != nil {
w.Log.Info("failed to parse default gateway", "defaultGateway", r.DefaultGateway, "err", err)
} else {
d.DefaultGateway = dg
}

// name servers, optional
for _, s := range r.NameServers {
ip := net.ParseIP(s)
if ip == nil {
w.Log.Info("failed to parse name server", "nameServer", s)
break
}
d.NameServers = append(d.NameServers, ip)
}

// hostname, optional
d.Hostname = r.Hostname

// domain name, optional
d.DomainName = r.DomainName

// broadcast address, optional
if ba, err := netip.ParseAddr(r.BroadcastAddress); err != nil {
w.Log.Info("failed to parse broadcast address", "broadcastAddress", r.BroadcastAddress, "err", err)
} else {
d.BroadcastAddress = ba
}

// ntp servers, optional
for _, s := range r.NTPServers {
ip := net.ParseIP(s)
if ip == nil {
w.Log.Info("failed to parse ntp server", "ntpServer", s)
break
}
d.NTPServers = append(d.NTPServers, ip)
}

// vlanid
d.VLANID = r.VLANID

// lease time
d.LeaseTime = uint32(r.LeaseTime)

// arch
d.Arch = r.Arch

// domain search
d.DomainSearch = r.DomainSearch

// allow machine to netboot
n.AllowNetboot = r.Netboot.AllowPXE

// ipxe script url is optional but if provided, it must be a valid url
if r.Netboot.IPXEScriptURL != "" {
u, err := url.Parse(r.Netboot.IPXEScriptURL)
if err != nil {
return nil, nil, fmt.Errorf("%w: %w", err, errParseURL)
}
n.IPXEScriptURL = u
}

// ipxe script
if r.Netboot.IPXEScript != "" {
n.IPXEScript = r.Netboot.IPXEScript
}

// console
if r.Netboot.Console != "" {
n.Console = r.Netboot.Console
}

// facility
if r.Netboot.Facility != "" {
n.Facility = r.Netboot.Facility
}

return d, n, nil
}
Loading

0 comments on commit 437581c

Please sign in to comment.