diff --git a/cmds/coredhcp/config.yml.example b/cmds/coredhcp/config.yml.example index c9cb91ea..44f4c7a9 100644 --- a/cmds/coredhcp/config.yml.example +++ b/cmds/coredhcp/config.yml.example @@ -137,6 +137,16 @@ server4: # The IP address should be one address where this server is reachable - server_id: 10.10.10.1 + # file serves leases defined in a static file, matching link-layer addresses to IPs + # - file: [autorefresh] + # The file format is one lease per line, " [netmask [gateway]]" + # where the netmask and gateway are optional. These are particularly useful + # when not all static leases are from the same subnet and have different + # routers and netmasks. + # When the 'autorefresh' argument is given, the plugin will try to refresh + # the lease mapping during runtime whenever the lease file is updated. + - file: "leases.txt" + # dns advertises DNS resolvers usable by the clients on this network # - dns: <...IP addresses> - dns: 8.8.8.8 8.8.4.4 diff --git a/plugins/file/plugin.go b/plugins/file/plugin.go index 4687cad3..d1f09aa4 100644 --- a/plugins/file/plugin.go +++ b/plugins/file/plugin.go @@ -4,11 +4,13 @@ // Package file enables static mapping of MAC <--> IP addresses. // The mapping is stored in a text file, where each mapping is described by one line containing -// two fields separated by spaces: MAC address, and IP address. For example: +// at least two fields separated by spaces: MAC address, and IP address. IPv4 addresses +// can be followed by netmask and gateway address. For example: // // $ cat file_leases.txt // 00:11:22:33:44:55 10.0.0.1 -// 01:23:45:67:89:01 10.0.10.10 +// 00:11:22:33:44:56 10.1.0.1 255.255.255.128 10.1.0.10 +// 01:23:45:67:89:01 2001:db8::10:2 // // To specify the plugin configuration in the server6/server4 sections of the config file, just // pass the leases file name as plugin argument, e.g.: @@ -40,6 +42,7 @@ import ( "github.com/coredhcp/coredhcp/handler" "github.com/coredhcp/coredhcp/logger" "github.com/coredhcp/coredhcp/plugins" + "github.com/coredhcp/coredhcp/plugins/netmask" "github.com/fsnotify/fsnotify" "github.com/insomniacslk/dhcp/dhcpv4" "github.com/insomniacslk/dhcp/dhcpv6" @@ -58,86 +61,78 @@ var Plugin = plugins.Plugin{ Setup4: setup4, } +// StaticRecord represents the data for a single static DHCP lease +type StaticRecord struct { + Address net.IP // IPv4 or IPv6 address + Netmask net.IPMask // for IPv4 records + Gateway net.IP // for IPv4 records +} + var recLock sync.RWMutex -// StaticRecords holds a MAC -> IP address mapping -var StaticRecords map[string]net.IP +// StaticRecords holds a MAC -> DHCP lease mapping +var StaticRecords map[string]StaticRecord -// DHCPv6Records and DHCPv4Records are mappings between MAC addresses in -// form of a string, to network configurations. -var ( - DHCPv6Records map[string]net.IP - DHCPv4Records map[string]net.IP -) - -// LoadDHCPv4Records loads the DHCPv4Records global map with records stored on -// the specified file. The records have to be one per line, a mac address and an -// IPv4 address. -func LoadDHCPv4Records(filename string) (map[string]net.IP, error) { +// loadDHCPRecords parses records stored on the specified file and returns a map +// of MAC address -> IPv4 or IPv6 leases. The records have to be one per line, +// a MAC address and an IPv4 or IPv6 address. For IPv4 records, an additional +// netmask and gateway address can be given as well. +func loadDHCPRecords(v6 bool, filename string) (map[string]StaticRecord, error) { log.Infof("reading leases from %s", filename) data, err := ioutil.ReadFile(filename) if err != nil { return nil, err } - records := make(map[string]net.IP) + records := make(map[string]StaticRecord) for _, lineBytes := range bytes.Split(data, []byte{'\n'}) { line := string(lineBytes) if len(line) == 0 { continue } + // parse config line if strings.HasPrefix(line, "#") { continue } tokens := strings.Fields(line) - if len(tokens) != 2 { - return nil, fmt.Errorf("malformed line, want 2 fields, got %d: %s", len(tokens), line) + if len(tokens) < 2 { + return nil, fmt.Errorf("malformed line, want at least 2 fields, got %d: %s", len(tokens), line) } + // parse MAC address hwaddr, err := net.ParseMAC(tokens[0]) if err != nil { return nil, fmt.Errorf("malformed hardware address: %s", tokens[0]) } + // parse IP address ipaddr := net.ParseIP(tokens[1]) - if ipaddr.To4() == nil { - return nil, fmt.Errorf("expected an IPv4 address, got: %v", ipaddr) + if v6 && (ipaddr.To16() == nil || ipaddr.To4() != nil) { + return nil, fmt.Errorf("expected an IPv6 address, got: %v", tokens[1]) + } else if !v6 && ipaddr.To4() == nil { + return nil, fmt.Errorf("expected an IPv4 address, got: %v", tokens[1]) } - records[hwaddr.String()] = ipaddr - } - return records, nil -} - -// LoadDHCPv6Records loads the DHCPv6Records global map with records stored on -// the specified file. The records have to be one per line, a mac address and an -// IPv6 address. -func LoadDHCPv6Records(filename string) (map[string]net.IP, error) { - log.Infof("reading leases from %s", filename) - data, err := ioutil.ReadFile(filename) - if err != nil { - return nil, err - } - records := make(map[string]net.IP) - for _, lineBytes := range bytes.Split(data, []byte{'\n'}) { - line := string(lineBytes) - if len(line) == 0 { - continue + lease := StaticRecord{ + Address: ipaddr, } - if strings.HasPrefix(line, "#") { - continue - } - tokens := strings.Fields(line) - if len(tokens) != 2 { - return nil, fmt.Errorf("malformed line, want 2 fields, got %d: %s", len(tokens), line) - } - hwaddr, err := net.ParseMAC(tokens[0]) - if err != nil { - return nil, fmt.Errorf("malformed hardware address: %s", tokens[0]) + + // parse netmask optionally for IPv4 records + if !v6 && len(tokens) > 2 { + lease.Netmask, err = netmask.ParseNetmask(tokens[2]) + if err != nil { + return nil, err + } } - ipaddr := net.ParseIP(tokens[1]) - if ipaddr.To16() == nil || ipaddr.To4() != nil { - return nil, fmt.Errorf("expected an IPv6 address, got: %v", ipaddr) + + // parse gateway optionally for IPv4 records + if !v6 && len(tokens) > 3 { + lease.Gateway = net.ParseIP(tokens[3]) + if lease.Gateway.To4() == nil { + return nil, fmt.Errorf("expected an IPv4 address, got: %v", tokens[3]) + } } - records[hwaddr.String()] = ipaddr + + records[hwaddr.String()] = lease } + return records, nil } @@ -164,18 +159,18 @@ func Handler6(req, resp dhcpv6.DHCPv6) (dhcpv6.DHCPv6, bool) { recLock.RLock() defer recLock.RUnlock() - ipaddr, ok := StaticRecords[mac.String()] + lease, ok := StaticRecords[mac.String()] if !ok { log.Warningf("MAC address %s is unknown", mac.String()) return resp, false } - log.Debugf("found IP address %s for MAC %s", ipaddr, mac.String()) + log.Debugf("found IP address %s for MAC %s", lease.Address, mac.String()) resp.AddOption(&dhcpv6.OptIANA{ IaId: m.Options.OneIANA().IaId, Options: dhcpv6.IdentityOptions{Options: []dhcpv6.Option{ &dhcpv6.OptIAAddress{ - IPv6Addr: ipaddr, + IPv6Addr: lease.Address, PreferredLifetime: 3600 * time.Second, ValidLifetime: 3600 * time.Second, }, @@ -189,13 +184,20 @@ func Handler4(req, resp *dhcpv4.DHCPv4) (*dhcpv4.DHCPv4, bool) { recLock.RLock() defer recLock.RUnlock() - ipaddr, ok := StaticRecords[req.ClientHWAddr.String()] + lease, ok := StaticRecords[req.ClientHWAddr.String()] if !ok { log.Warningf("MAC address %s is unknown", req.ClientHWAddr.String()) return resp, false } - resp.YourIPAddr = ipaddr - log.Debugf("found IP address %s for MAC %s", ipaddr, req.ClientHWAddr.String()) + log.Debugf("found IP address %s for MAC %s", lease.Address, req.ClientHWAddr.String()) + + resp.YourIPAddr = lease.Address + if len(lease.Netmask) > 0 { + resp.Options.Update(dhcpv4.OptSubnetMask(lease.Netmask)) + } + if lease.Gateway.To4() != nil { + resp.Options.Update(dhcpv4.OptRouter(lease.Gateway)) + } return resp, true } @@ -210,7 +212,6 @@ func setup4(args ...string) (handler.Handler4, error) { } func setupFile(v6 bool, args ...string) (handler.Handler6, handler.Handler4, error) { - var err error if len(args) < 1 { return nil, nil, errors.New("need a file name") } @@ -220,7 +221,7 @@ func setupFile(v6 bool, args ...string) (handler.Handler6, handler.Handler4, err } // load initial database from lease file - if err = loadFromFile(v6, filename); err != nil { + if err := loadFromFile(v6, filename); err != nil { return nil, nil, err } @@ -259,16 +260,11 @@ func setupFile(v6 bool, args ...string) (handler.Handler6, handler.Handler4, err } func loadFromFile(v6 bool, filename string) error { - var err error - var records map[string]net.IP - var protver int + protver := 4 if v6 { protver = 6 - records, err = LoadDHCPv6Records(filename) - } else { - protver = 4 - records, err = LoadDHCPv4Records(filename) } + records, err := loadDHCPRecords(v6, filename) if err != nil { return fmt.Errorf("failed to load DHCPv%d records: %w", protver, err) } diff --git a/plugins/file/plugin_test.go b/plugins/file/plugin_test.go index b38f3bad..1efd7523 100644 --- a/plugins/file/plugin_test.go +++ b/plugins/file/plugin_test.go @@ -30,22 +30,26 @@ func TestLoadDHCPv4Records(t *testing.T) { // fill temp file with valid lease lines and some comments _, err = tmp.WriteString("00:11:22:33:44:55 192.0.2.100\n") require.NoError(t, err) - _, err = tmp.WriteString("11:22:33:44:55:66 192.0.2.101\n") + _, err = tmp.WriteString("11:22:33:44:55:66 192.0.2.101 255.255.255.0 192.0.2.1\n") require.NoError(t, err) _, err = tmp.WriteString("# this is a comment\n") require.NoError(t, err) - records, err := LoadDHCPv4Records(tmp.Name()) + records, err := loadDHCPRecords(false, tmp.Name()) if !assert.NoError(t, err) { return } if assert.Equal(t, 2, len(records)) { if assert.Contains(t, records, "00:11:22:33:44:55") { - assert.Equal(t, net.ParseIP("192.0.2.100"), records["00:11:22:33:44:55"]) + assert.Equal(t, net.ParseIP("192.0.2.100"), records["00:11:22:33:44:55"].Address) + assert.Equal(t, 0, len(records["00:11:22:33:44:55"].Netmask)) + assert.Equal(t, 0, len(records["00:11:22:33:44:55"].Gateway)) } if assert.Contains(t, records, "11:22:33:44:55:66") { - assert.Equal(t, net.ParseIP("192.0.2.101"), records["11:22:33:44:55:66"]) + assert.Equal(t, net.ParseIP("192.0.2.101"), records["11:22:33:44:55:66"].Address) + assert.Equal(t, net.IPv4Mask(255, 255, 255, 0), records["11:22:33:44:55:66"].Netmask) + assert.Equal(t, net.ParseIP("192.0.2.1"), records["11:22:33:44:55:66"].Gateway) } } }) @@ -62,7 +66,7 @@ func TestLoadDHCPv4Records(t *testing.T) { // add line with too few fields _, err = tmp.WriteString("foo\n") require.NoError(t, err) - _, err = LoadDHCPv4Records(tmp.Name()) + _, err = loadDHCPRecords(false, tmp.Name()) assert.Error(t, err) }) @@ -78,7 +82,7 @@ func TestLoadDHCPv4Records(t *testing.T) { // add line with invalid MAC address to trigger an error _, err = tmp.WriteString("abcd 192.0.2.102\n") require.NoError(t, err) - _, err = LoadDHCPv4Records(tmp.Name()) + _, err = loadDHCPRecords(false, tmp.Name()) assert.Error(t, err) }) @@ -94,7 +98,7 @@ func TestLoadDHCPv4Records(t *testing.T) { // add line with invalid MAC address to trigger an error _, err = tmp.WriteString("22:33:44:55:66:77 bcde\n") require.NoError(t, err) - _, err = LoadDHCPv4Records(tmp.Name()) + _, err = loadDHCPRecords(false, tmp.Name()) assert.Error(t, err) }) @@ -110,7 +114,39 @@ func TestLoadDHCPv4Records(t *testing.T) { // add line with IPv6 address instead to trigger an error _, err = tmp.WriteString("00:11:22:33:44:55 2001:db8::10:1\n") require.NoError(t, err) - _, err = LoadDHCPv4Records(tmp.Name()) + _, err = loadDHCPRecords(false, tmp.Name()) + assert.Error(t, err) + }) + + t.Run("invalid netmask", func(t *testing.T) { + // setup temp leases file + tmp, err := ioutil.TempFile("", "test_plugin_file") + require.NoError(t, err) + defer func() { + tmp.Close() + os.Remove(tmp.Name()) + }() + + // add line with invalid netmask to trigger an error + _, err = tmp.WriteString("22:33:44:55:66:77 192.0.2.101 0.255.255.255\n") + require.NoError(t, err) + _, err = loadDHCPRecords(false, tmp.Name()) + assert.Error(t, err) + }) + + t.Run("invalid gateway", func(t *testing.T) { + // setup temp leases file + tmp, err := ioutil.TempFile("", "test_plugin_file") + require.NoError(t, err) + defer func() { + tmp.Close() + os.Remove(tmp.Name()) + }() + + // add line with invalid netmask to trigger an error + _, err = tmp.WriteString("22:33:44:55:66:77 192.0.2.101 255.255.255.0 abcd\n") + require.NoError(t, err) + _, err = loadDHCPRecords(false, tmp.Name()) assert.Error(t, err) }) } @@ -133,17 +169,17 @@ func TestLoadDHCPv6Records(t *testing.T) { _, err = tmp.WriteString("# this is a comment\n") require.NoError(t, err) - records, err := LoadDHCPv6Records(tmp.Name()) + records, err := loadDHCPRecords(true, tmp.Name()) if !assert.NoError(t, err) { return } if assert.Equal(t, 2, len(records)) { if assert.Contains(t, records, "00:11:22:33:44:55") { - assert.Equal(t, net.ParseIP("2001:db8::10:1"), records["00:11:22:33:44:55"]) + assert.Equal(t, net.ParseIP("2001:db8::10:1"), records["00:11:22:33:44:55"].Address) } if assert.Contains(t, records, "11:22:33:44:55:66") { - assert.Equal(t, net.ParseIP("2001:db8::10:2"), records["11:22:33:44:55:66"]) + assert.Equal(t, net.ParseIP("2001:db8::10:2"), records["11:22:33:44:55:66"].Address) } } }) @@ -160,7 +196,7 @@ func TestLoadDHCPv6Records(t *testing.T) { // add line with too few fields _, err = tmp.WriteString("foo\n") require.NoError(t, err) - _, err = LoadDHCPv6Records(tmp.Name()) + _, err = loadDHCPRecords(true, tmp.Name()) assert.Error(t, err) }) @@ -176,7 +212,7 @@ func TestLoadDHCPv6Records(t *testing.T) { // add line with invalid MAC address to trigger an error _, err = tmp.WriteString("abcd 2001:db8::10:3\n") require.NoError(t, err) - _, err = LoadDHCPv6Records(tmp.Name()) + _, err = loadDHCPRecords(true, tmp.Name()) assert.Error(t, err) }) @@ -192,7 +228,7 @@ func TestLoadDHCPv6Records(t *testing.T) { // add line with invalid MAC address to trigger an error _, err = tmp.WriteString("22:33:44:55:66:77 bcde\n") require.NoError(t, err) - _, err = LoadDHCPv6Records(tmp.Name()) + _, err = loadDHCPRecords(true, tmp.Name()) assert.Error(t, err) }) @@ -208,7 +244,7 @@ func TestLoadDHCPv6Records(t *testing.T) { // add line with IPv4 address instead to trigger an error _, err = tmp.WriteString("00:11:22:33:44:55 192.0.2.100\n") require.NoError(t, err) - _, err = LoadDHCPv6Records(tmp.Name()) + _, err = loadDHCPRecords(true, tmp.Name()) assert.Error(t, err) }) } @@ -244,8 +280,69 @@ func TestHandler4(t *testing.T) { // add lease for the MAC in the lease map clIPAddr := net.ParseIP("192.0.2.100") - StaticRecords = map[string]net.IP{ - mac: clIPAddr, + StaticRecords = map[string]StaticRecord{ + mac: {Address: clIPAddr}, + } + + // if we handle this DHCP request, the YourIPAddr field should be set + // in the result + result, stop := Handler4(req, resp) + assert.Same(t, result, resp) + assert.True(t, stop) + assert.Equal(t, clIPAddr, result.YourIPAddr) + assert.Nil(t, result.Options.Get(dhcpv4.OptionSubnetMask)) + assert.Nil(t, result.Options.Get(dhcpv4.OptionRouter)) + + // cleanup + StaticRecords = make(map[string]StaticRecord) + }) + + t.Run("known MAC with netmask", func(t *testing.T) { + // prepare DHCPv4 request + mac := "00:11:22:33:44:55" + claddr, _ := net.ParseMAC(mac) + req := &dhcpv4.DHCPv4{ + ClientHWAddr: claddr, + } + resp := &dhcpv4.DHCPv4{Options: dhcpv4.Options{}} + assert.Nil(t, resp.ClientIPAddr) + + // add lease for the MAC in the lease map + clIPAddr := net.ParseIP("192.0.2.100") + snMask := net.IPv4Mask(255, 255, 255, 0) + StaticRecords = map[string]StaticRecord{ + mac: {Address: clIPAddr, Netmask: snMask}, + } + + // if we handle this DHCP request, the YourIPAddr field should be set + // in the result + result, stop := Handler4(req, resp) + assert.Same(t, result, resp) + assert.True(t, stop) + assert.Equal(t, clIPAddr, result.YourIPAddr) + assert.EqualValues(t, snMask, result.Options.Get(dhcpv4.OptionSubnetMask)) + assert.Nil(t, result.Options.Get(dhcpv4.OptionRouter)) + + // cleanup + StaticRecords = make(map[string]StaticRecord) + }) + + t.Run("known MAC with netmask and gateway", func(t *testing.T) { + // prepare DHCPv4 request + mac := "00:11:22:33:44:55" + claddr, _ := net.ParseMAC(mac) + req := &dhcpv4.DHCPv4{ + ClientHWAddr: claddr, + } + resp := &dhcpv4.DHCPv4{Options: dhcpv4.Options{}} + assert.Nil(t, resp.ClientIPAddr) + + // add lease for the MAC in the lease map + clIPAddr := net.ParseIP("192.0.2.100") + snMask := net.IPv4Mask(255, 255, 255, 0) + gwAddr := net.ParseIP("192.0.2.1") + StaticRecords = map[string]StaticRecord{ + mac: {Address: clIPAddr, Netmask: snMask, Gateway: gwAddr}, } // if we handle this DHCP request, the YourIPAddr field should be set @@ -254,9 +351,11 @@ func TestHandler4(t *testing.T) { assert.Same(t, result, resp) assert.True(t, stop) assert.Equal(t, clIPAddr, result.YourIPAddr) + assert.EqualValues(t, snMask, result.Options.Get(dhcpv4.OptionSubnetMask)) + assert.EqualValues(t, gwAddr.To4(), result.Options.Get(dhcpv4.OptionRouter)) // cleanup - StaticRecords = make(map[string]net.IP) + StaticRecords = make(map[string]StaticRecord) }) } @@ -290,8 +389,8 @@ func TestHandler6(t *testing.T) { // add lease for the MAC in the lease map clIPAddr := net.ParseIP("2001:db8::10:1") - StaticRecords = map[string]net.IP{ - mac: clIPAddr, + StaticRecords = map[string]StaticRecord{ + mac: {Address: clIPAddr}, } // if we handle this DHCP request, there should be a specific IANA option @@ -304,7 +403,7 @@ func TestHandler6(t *testing.T) { } // cleanup - StaticRecords = make(map[string]net.IP) + StaticRecords = make(map[string]StaticRecord) }) } diff --git a/plugins/netmask/plugin.go b/plugins/netmask/plugin.go index d1515470..66bed87d 100644 --- a/plugins/netmask/plugin.go +++ b/plugins/netmask/plugin.go @@ -7,6 +7,7 @@ package netmask import ( "encoding/binary" "errors" + "fmt" "net" "github.com/coredhcp/coredhcp/handler" @@ -32,17 +33,10 @@ func setup4(args ...string) (handler.Handler4, error) { if len(args) != 1 { return nil, errors.New("need at least one netmask IP address") } - netmaskIP := net.ParseIP(args[0]) - if netmaskIP.IsUnspecified() { - return nil, errors.New("netmask is not valid, got: " + args[0]) - } - netmaskIP = netmaskIP.To4() - if netmaskIP == nil { - return nil, errors.New("expected an netmask address, got: " + args[0]) - } - netmask = net.IPv4Mask(netmaskIP[0], netmaskIP[1], netmaskIP[2], netmaskIP[3]) - if !checkValidNetmask(netmask) { - return nil, errors.New("netmask is not valid, got: " + args[0]) + var err error + netmask, err = ParseNetmask(args[0]) + if err != nil { + return nil, err } log.Printf("loaded client netmask") return Handler4, nil @@ -54,6 +48,24 @@ func Handler4(req, resp *dhcpv4.DHCPv4) (*dhcpv4.DHCPv4, bool) { return resp, false } +// ParseNetmask parses and validates given string as netmask and returns IPMask +func ParseNetmask(nm string) (net.IPMask, error) { + netmaskIP := net.ParseIP(nm) + if netmaskIP.IsUnspecified() { + return nil, fmt.Errorf("netmask is not valid, got: %s", nm) + } + netmaskIP = netmaskIP.To4() + if netmaskIP == nil { + return nil, fmt.Errorf("expected an netmask address, got: %s", nm) + } + netmask := net.IPv4Mask(netmaskIP[0], netmaskIP[1], netmaskIP[2], netmaskIP[3]) + if !checkValidNetmask(netmask) { + return nil, fmt.Errorf("netmask is not valid, got: %s ", nm) + } + + return netmask, nil +} + func checkValidNetmask(netmask net.IPMask) bool { netmaskInt := binary.BigEndian.Uint32(netmask) x := ^netmaskInt