From 9c999f98fb9bb2294e8031d75f0396277dfe465b Mon Sep 17 00:00:00 2001
From: Simon Zolin <s.zolin@adguard.com>
Date: Tue, 25 Aug 2020 14:07:11 +0300
Subject: [PATCH] + dhcp custom options

Squashed commit of the following:

commit 140ac16568383cab2270e5d5ba895959902dd943
Merge: d5ed73b5 cb6ca3b0
Author: Simon Zolin <s.zolin@adguard.com>
Date:   Tue Aug 25 13:46:34 2020 +0300

    Merge remote-tracking branch 'origin/master' into 1585-dhcp-options

commit d5ed73b5e4f068b823fe97ab1161753670d10387
Author: Simon Zolin <s.zolin@adguard.com>
Date:   Fri Aug 21 18:16:41 2020 +0300

    minor

commit f5208a0b050c2dd462b32edee0379758cc6e5003
Author: Simon Zolin <s.zolin@adguard.com>
Date:   Mon Jun 1 14:09:39 2020 +0300

    + dhcpv4 custom options
---
 AGHTechDoc.md       | 34 ++++++++++++++++++++++++----------
 dhcpd/dhcpd.go      | 44 ++++++++++++++++++++++++++++++++++++++++++++
 dhcpd/dhcpd_test.go | 22 ++++++++++++++++++++++
 dhcpd/server.go     | 16 ++++++++++++++++
 dhcpd/v4.go         | 18 ++++++++++++++++++
 dhcpd/v4_test.go    |  6 ++++++
 6 files changed, 130 insertions(+), 10 deletions(-)

diff --git a/AGHTechDoc.md b/AGHTechDoc.md
index 99bc6a8c..b0b6ca80 100644
--- a/AGHTechDoc.md
+++ b/AGHTechDoc.md
@@ -25,12 +25,13 @@ Contents:
 	* API: Find clients by IP
 * DHCP server
 	* DHCP server in DNS
-	* "Show DHCP interfaces" command
-	* "Show DHCP status" command
-	* "Check DHCP" command
-	* "Enable DHCP" command
+	* DHCP Custom Options
+	* API: Show DHCP interfaces
+	* API: Show DHCP status
+	* API: Check DHCP
+	* API: Enable DHCP
 	* Static IP check/set
-	* Add a static lease
+	* API: Add a static lease
 	* API: Reset DHCP configuration
 * DNS general settings
 	* API: Get DNS general settings
@@ -429,7 +430,20 @@ DHCP leases are used in several ways by DNS module.
 		> PTR 100.1.168.192.in-addr.arpa. = bills-notebook.
 
 
-### "Show DHCP interfaces" command
+### DHCP Custom Options
+
+Option with arbitrary hexadecimal data:
+
+	DEC_CODE hex HEX_DATA
+
+where DEC_CODE is a decimal DHCPv4 option code in range [1..255]
+
+Option with IP data (only 1 IP is supported):
+
+	DEC_CODE ip IP_ADDR
+
+
+### API: Show DHCP interfaces
 
 Request:
 
@@ -452,7 +466,7 @@ Response:
 	}
 
 
-### "Show DHCP status" command
+### API: Show DHCP status
 
 Request:
 
@@ -487,7 +501,7 @@ Response:
 	}
 
 
-### "Check DHCP" command
+### API: Check DHCP
 
 Request:
 
@@ -535,7 +549,7 @@ If `static_ip.static` is:
 		In order to use DHCP server a static IP address must be set.  We failed to determine if this network interface is configured using static IP address.  Please set a static IP address manually.
 
 
-### "Enable DHCP" command
+### API: Enable DHCP
 
 Request:
 
@@ -662,7 +676,7 @@ or:
 	systemctl restart system-networkd
 
 
-### Add a static lease
+### API: Add a static lease
 
 Request:
 
diff --git a/dhcpd/dhcpd.go b/dhcpd/dhcpd.go
index f6e19d5a..46beebca 100644
--- a/dhcpd/dhcpd.go
+++ b/dhcpd/dhcpd.go
@@ -1,11 +1,15 @@
 package dhcpd
 
 import (
+	"encoding/hex"
 	"net"
 	"net/http"
 	"path/filepath"
+	"strconv"
+	"strings"
 	"time"
 
+	"github.com/AdguardTeam/AdGuardHome/util"
 	"github.com/AdguardTeam/golibs/log"
 )
 
@@ -214,3 +218,43 @@ func (s *Server) FindMACbyIP(ip net.IP) net.HardwareAddr {
 func (s *Server) AddStaticLease(lease Lease) error {
 	return s.srv4.AddStaticLease(lease)
 }
+
+// Parse option string
+// Format:
+// CODE TYPE VALUE
+func parseOptionString(s string) (uint8, []byte) {
+	s = strings.TrimSpace(s)
+	scode := util.SplitNext(&s, ' ')
+	t := util.SplitNext(&s, ' ')
+	sval := util.SplitNext(&s, ' ')
+
+	code, err := strconv.Atoi(scode)
+	if err != nil || code <= 0 || code > 255 {
+		return 0, nil
+	}
+
+	var val []byte
+
+	switch t {
+	case "hex":
+		val, err = hex.DecodeString(sval)
+		if err != nil {
+			return 0, nil
+		}
+
+	case "ip":
+		ip := net.ParseIP(sval)
+		if ip == nil {
+			return 0, nil
+		}
+		val = ip
+		if ip.To4() != nil {
+			val = ip.To4()
+		}
+
+	default:
+		return 0, nil
+	}
+
+	return uint8(code), val
+}
diff --git a/dhcpd/dhcpd_test.go b/dhcpd/dhcpd_test.go
index ab31c900..9e4222cf 100644
--- a/dhcpd/dhcpd_test.go
+++ b/dhcpd/dhcpd_test.go
@@ -108,3 +108,25 @@ func TestNormalizeLeases(t *testing.T) {
 	assert.True(t, bytes.Equal(leases[1].HWAddr, []byte{2, 2, 3, 4}))
 	assert.True(t, bytes.Equal(leases[2].HWAddr, []byte{1, 2, 3, 5}))
 }
+
+func TestOptions(t *testing.T) {
+	code, val := parseOptionString(" 12  hex  abcdef ")
+	assert.Equal(t, uint8(12), code)
+	assert.True(t, bytes.Equal([]byte{0xab, 0xcd, 0xef}, val))
+
+	code, _ = parseOptionString(" 12  hex  abcdef1 ")
+	assert.Equal(t, uint8(0), code)
+
+	code, val = parseOptionString("123 ip 1.2.3.4")
+	assert.Equal(t, uint8(123), code)
+	assert.Equal(t, "1.2.3.4", net.IP(string(val)).String())
+
+	code, _ = parseOptionString("256 ip 1.1.1.1")
+	assert.Equal(t, uint8(0), code)
+	code, _ = parseOptionString("-1 ip 1.1.1.1")
+	assert.Equal(t, uint8(0), code)
+	code, _ = parseOptionString("12 ip 1.1.1.1x")
+	assert.Equal(t, uint8(0), code)
+	code, _ = parseOptionString("12 x 1.1.1.1")
+	assert.Equal(t, uint8(0), code)
+}
diff --git a/dhcpd/server.go b/dhcpd/server.go
index 5aea9497..1701c780 100644
--- a/dhcpd/server.go
+++ b/dhcpd/server.go
@@ -50,12 +50,23 @@ type V4ServerConf struct {
 	// 0: disable
 	ICMPTimeout uint32 `yaml:"icmp_timeout_msec"`
 
+	// Custom Options.
+	//
+	// Option with arbitrary hexadecimal data:
+	//     DEC_CODE hex HEX_DATA
+	// where DEC_CODE is a decimal DHCPv4 option code in range [1..255]
+	//
+	// Option with IP data (only 1 IP is supported):
+	//     DEC_CODE ip IP_ADDR
+	Options []string `yaml:"options"`
+
 	ipStart    net.IP        // starting IP address for dynamic leases
 	ipEnd      net.IP        // ending IP address for dynamic leases
 	leaseTime  time.Duration // the time during which a dynamic lease is considered valid
 	dnsIPAddrs []net.IP      // IPv4 addresses to return to DHCP clients as DNS server addresses
 	routerIP   net.IP        // value for Option Router
 	subnetMask net.IPMask    // value for Option SubnetMask
+	options    []dhcpOption
 
 	// Server calls this function when leases data changes
 	notify func(uint32)
@@ -79,3 +90,8 @@ type V6ServerConf struct {
 	// Server calls this function when leases data changes
 	notify func(uint32)
 }
+
+type dhcpOption struct {
+	code uint8
+	val  []byte
+}
diff --git a/dhcpd/v4.go b/dhcpd/v4.go
index ac0f9808..fdc63cd4 100644
--- a/dhcpd/v4.go
+++ b/dhcpd/v4.go
@@ -475,6 +475,10 @@ func (s *v4Server) process(req *dhcpv4.DHCPv4, resp *dhcpv4.DHCPv4) int {
 	resp.UpdateOption(dhcpv4.OptRouter(s.conf.routerIP))
 	resp.UpdateOption(dhcpv4.OptSubnetMask(s.conf.subnetMask))
 	resp.UpdateOption(dhcpv4.OptDNS(s.conf.dnsIPAddrs...))
+
+	for _, opt := range s.conf.options {
+		resp.Options[opt.code] = opt.val
+	}
 	return 1
 }
 
@@ -619,5 +623,19 @@ func v4Create(conf V4ServerConf) (DHCPServer, error) {
 		s.conf.leaseTime = time.Second * time.Duration(conf.LeaseDuration)
 	}
 
+	for _, o := range conf.Options {
+		code, val := parseOptionString(o)
+		if code == 0 {
+			log.Debug("DHCPv4: bad option string: %s", o)
+			continue
+		}
+
+		opt := dhcpOption{
+			code: code,
+			val:  val,
+		}
+		s.conf.options = append(s.conf.options, opt)
+	}
+
 	return s, nil
 }
diff --git a/dhcpd/v4_test.go b/dhcpd/v4_test.go
index 208dd8fb..fe3ac2dd 100644
--- a/dhcpd/v4_test.go
+++ b/dhcpd/v4_test.go
@@ -178,6 +178,10 @@ func TestV4DynamicLeaseGet(t *testing.T) {
 		GatewayIP:  "192.168.10.1",
 		SubnetMask: "255.255.255.0",
 		notify:     notify4,
+		Options: []string{
+			"81 hex 303132",
+			"82 ip 1.2.3.4",
+		},
 	}
 	sIface, err := v4Create(conf)
 	s := sIface.(*v4Server)
@@ -198,6 +202,8 @@ func TestV4DynamicLeaseGet(t *testing.T) {
 	assert.Equal(t, "192.168.10.1", resp.ServerIdentifier().String())
 	assert.Equal(t, "255.255.255.0", net.IP(resp.SubnetMask()).String())
 	assert.Equal(t, s.conf.leaseTime.Seconds(), resp.IPAddressLeaseTime(-1).Seconds())
+	assert.Equal(t, []byte("012"), resp.Options[uint8(dhcpv4.OptionFQDN)])
+	assert.Equal(t, "1.2.3.4", net.IP(resp.Options[uint8(dhcpv4.OptionRelayAgentInformation)]).String())
 
 	// "Request"
 	req, _ = dhcpv4.NewRequestFromOffer(resp)