From 8b4a1ca71361c14f19dd1fbd8549235d9df0a2bb Mon Sep 17 00:00:00 2001
From: Eugene Bujak <hmage@hmage.net>
Date: Fri, 28 Dec 2018 17:17:51 +0300
Subject: [PATCH] First implementation of DHCP server, compiles but not tested
 yet.

---
 config.go                 |  48 +----
 dhcp.go                   |  36 +++-
 dhcpd/check_other_dhcp.go | 143 ++++++++++++++
 dhcpd/dhcpd.go            | 389 ++++++++++++++++++++++++++++++++++++++
 dhcpd/filter_conn.go      |  62 ++++++
 dhcpd/helpers.go          | 101 ++++++++++
 dhcpd/standalone/main.go  | 111 +++++++++++
 go.mod                    |   2 +
 go.sum                    |   2 +
 9 files changed, 847 insertions(+), 47 deletions(-)
 create mode 100644 dhcpd/check_other_dhcp.go
 create mode 100644 dhcpd/dhcpd.go
 create mode 100644 dhcpd/filter_conn.go
 create mode 100644 dhcpd/helpers.go
 create mode 100644 dhcpd/standalone/main.go

diff --git a/config.go b/config.go
index 89ec9e20..15ecffc1 100644
--- a/config.go
+++ b/config.go
@@ -6,8 +6,8 @@ import (
 	"os"
 	"path/filepath"
 	"sync"
-	"time"
 
+	"github.com/AdguardTeam/AdGuardHome/dhcpd"
 	"github.com/AdguardTeam/AdGuardHome/dnsfilter"
 	"github.com/AdguardTeam/AdGuardHome/dnsforward"
 	"gopkg.in/yaml.v2"
@@ -24,15 +24,15 @@ type configuration struct {
 	ourConfigFilename string // Config filename (can be overriden via the command line arguments)
 	ourBinaryDir      string // Location of our directory, used to protect against CWD being somewhere else
 
-	BindHost  string    `yaml:"bind_host"`
-	BindPort  int       `yaml:"bind_port"`
-	AuthName  string    `yaml:"auth_name"`
-	AuthPass  string    `yaml:"auth_pass"`
-	Language  string    `yaml:"language"` // two-letter ISO 639-1 language code
-	DNS       dnsConfig `yaml:"dns"`
-	Filters   []filter  `yaml:"filters"`
-	UserRules []string  `yaml:"user_rules"`
-	DHCP      dhcpState `yaml:"dhcp"`
+	BindHost  string             `yaml:"bind_host"`
+	BindPort  int                `yaml:"bind_port"`
+	AuthName  string             `yaml:"auth_name"`
+	AuthPass  string             `yaml:"auth_pass"`
+	Language  string             `yaml:"language"` // two-letter ISO 639-1 language code
+	DNS       dnsConfig          `yaml:"dns"`
+	Filters   []filter           `yaml:"filters"`
+	UserRules []string           `yaml:"user_rules"`
+	DHCP      dhcpd.ServerConfig `yaml:"dhcp"`
 
 	sync.RWMutex `yaml:"-"`
 
@@ -50,31 +50,6 @@ type dnsConfig struct {
 
 var defaultDNS = []string{"tls://1.1.1.1", "tls://1.0.0.1"}
 
-// field ordering is important -- yaml fields will mirror ordering from here
-type dhcpState struct {
-	Config dhcpConfig
-	Leases []dhcpLease
-}
-
-// field ordering is important -- yaml fields will mirror ordering from here
-type dhcpConfig struct {
-	Enabled       bool   `json:"enabled" yaml:"enabled"`
-	InterfaceName string `json:"interface_name" yaml:"interface_name"` // eth0, en0 and so on
-	GatewayIP     string `json:"gateway_ip" yaml:"gateway_ip"`
-	SubnetMask    string `json:"subnet_mask" yaml:"subnet_mask"`
-	RangeStart    string `json:"range_start" yaml:"range_start"`
-	RangeEnd      string `json:"range_end" yaml:"range_end"`
-	LeaseDuration uint64 `json:"lease_duration" yaml:"lease_duration"` // in seconds
-}
-
-// field ordering is important -- yaml fields will mirror ordering from here
-type dhcpLease struct {
-	HWAddr   [6]byte `json:"mac" yaml:"hwaddr"`
-	IP       string  `json:"ip"` // json by default keeps IP uppercase but we need lowercase
-	Hostname string
-	Expires  time.Time
-}
-
 // initialize to default values, will be changed later when reading config or parsing command line
 var config = configuration{
 	ourConfigFilename: "AdGuardHome.yaml",
@@ -99,9 +74,6 @@ var config = configuration{
 		{Filter: dnsfilter.Filter{ID: 3}, Enabled: false, URL: "https://hosts-file.net/ad_servers.txt", Name: "hpHosts - Ad and Tracking servers only"},
 		{Filter: dnsfilter.Filter{ID: 4}, Enabled: false, URL: "http://www.malwaredomainlist.com/hostslist/hosts.txt", Name: "MalwareDomainList.com Hosts List"},
 	},
-	DHCP: dhcpState{Config: dhcpConfig{
-		LeaseDuration: 12 * 60 * 60, // in seconds
-	}},
 	SchemaVersion: currentSchemaVersion,
 }
 
diff --git a/dhcp.go b/dhcp.go
index eb30ab75..b86fa29c 100644
--- a/dhcp.go
+++ b/dhcp.go
@@ -2,15 +2,18 @@ package main
 
 import (
 	"encoding/json"
-	"math/rand"
 	"net"
 	"net/http"
+
+	"github.com/AdguardTeam/AdGuardHome/dhcpd"
 )
 
+var dhcpServer = dhcpd.Server{}
+
 func handleDHCPStatus(w http.ResponseWriter, r *http.Request) {
 	status := map[string]interface{}{
-		"config": config.DHCP.Config,
-		"leases": config.DHCP.Leases,
+		"config": config.DHCP,
+		"leases": dhcpServer.Leases(),
 	}
 
 	w.Header().Set("Content-Type", "application/json")
@@ -22,14 +25,24 @@ func handleDHCPStatus(w http.ResponseWriter, r *http.Request) {
 }
 
 func handleDHCPSetConfig(w http.ResponseWriter, r *http.Request) {
-	newconfig := dhcpConfig{}
+	newconfig := dhcpd.ServerConfig{}
 	err := json.NewDecoder(r.Body).Decode(&newconfig)
 	if err != nil {
 		httpError(w, http.StatusBadRequest, "Failed to parse new DHCP config json: %s", err)
 		return
 	}
 
-	config.DHCP.Config = newconfig
+	if newconfig.Enabled {
+		err := dhcpServer.Start(&newconfig)
+		if err != nil {
+			httpError(w, http.StatusBadRequest, "Failed to start DHCP server: %s", err)
+			return
+		}
+	}
+	if !newconfig.Enabled {
+		dhcpServer.Stop()
+	}
+	config.DHCP = newconfig
 }
 
 func handleDHCPInterfaces(w http.ResponseWriter, r *http.Request) {
@@ -93,13 +106,18 @@ func handleDHCPInterfaces(w http.ResponseWriter, r *http.Request) {
 	}
 }
 
-// TODO: implement
+// implement
 func handleDHCPFindActiveServer(w http.ResponseWriter, r *http.Request) {
-	found := map[string]bool{
-		"found": rand.Intn(2) == 1,
+	found, err := dhcpd.CheckIfOtherDHCPServersPresent(config.DHCP.InterfaceName)
+	result := map[string]interface{}{
+		"found": found,
+	}
+	if err != nil {
+		result["found"] = false
+		result["error"] = err
 	}
 	w.Header().Set("Content-Type", "application/json")
-	err := json.NewEncoder(w).Encode(found)
+	err = json.NewEncoder(w).Encode(result)
 	if err != nil {
 		httpError(w, http.StatusInternalServerError, "Failed to marshal DHCP found json: %s", err)
 		return
diff --git a/dhcpd/check_other_dhcp.go b/dhcpd/check_other_dhcp.go
new file mode 100644
index 00000000..7aed85ce
--- /dev/null
+++ b/dhcpd/check_other_dhcp.go
@@ -0,0 +1,143 @@
+package dhcpd
+
+import (
+	"crypto/rand"
+	"encoding/binary"
+	"fmt"
+	"math"
+	"net"
+	"os"
+	"time"
+
+	"github.com/krolaw/dhcp4"
+)
+
+func CheckIfOtherDHCPServersPresent(ifaceName string) (bool, error) {
+	iface, err := net.InterfaceByName(ifaceName)
+	if err != nil {
+		return false, wrapErrPrint(err, "Couldn't find interface by name %s", ifaceName)
+	}
+
+	// get ipv4 address of an interface
+	ifaceIPNet := getIfaceIPv4(iface)
+	if ifaceIPNet == nil {
+		return false, fmt.Errorf("Couldn't find IPv4 address of interface %s %+v", ifaceName, iface)
+	}
+
+	srcIP := ifaceIPNet.IP
+	src := net.JoinHostPort(srcIP.String(), "68")
+	dst := "255.255.255.255:67"
+
+	// form a DHCP request packet, try to emulate existing client as much as possible
+	xId := make([]byte, 8)
+	n, err := rand.Read(xId)
+	if n != 8 && err == nil {
+		err = fmt.Errorf("Generated less than 8 bytes")
+	}
+	if err != nil {
+		return false, wrapErrPrint(err, "Couldn't generate 8 random bytes")
+	}
+	hostname, err := os.Hostname()
+	if err != nil {
+		return false, wrapErrPrint(err, "Couldn't get hostname")
+	}
+	requestList := []byte{
+		byte(dhcp4.OptionSubnetMask),
+		byte(dhcp4.OptionClasslessRouteFormat),
+		byte(dhcp4.OptionRouter),
+		byte(dhcp4.OptionDomainNameServer),
+		byte(dhcp4.OptionDomainName),
+		byte(dhcp4.OptionDomainSearch),
+		252, // private/proxy autodiscovery
+		95,  // LDAP
+		byte(dhcp4.OptionNetBIOSOverTCPIPNameServer),
+		byte(dhcp4.OptionNetBIOSOverTCPIPNodeType),
+	}
+	maxUDPsizeRaw := make([]byte, 2)
+	binary.BigEndian.PutUint16(maxUDPsizeRaw, 1500)
+	leaseTimeRaw := make([]byte, 4)
+	leaseTime := uint32(math.RoundToEven(time.Duration(time.Hour * 24 * 90).Seconds()))
+	binary.BigEndian.PutUint32(leaseTimeRaw, leaseTime)
+	options := []dhcp4.Option{
+		{dhcp4.OptionParameterRequestList, requestList},
+		{dhcp4.OptionMaximumDHCPMessageSize, maxUDPsizeRaw},
+		{dhcp4.OptionClientIdentifier, append([]byte{0x01}, iface.HardwareAddr...)},
+		{dhcp4.OptionIPAddressLeaseTime, leaseTimeRaw},
+		{dhcp4.OptionHostName, []byte(hostname)},
+	}
+	packet := dhcp4.RequestPacket(dhcp4.Discover, iface.HardwareAddr, nil, xId, false, options)
+
+	// resolve 0.0.0.0:68
+	udpAddr, err := net.ResolveUDPAddr("udp4", src)
+	if err != nil {
+		return false, wrapErrPrint(err, "Couldn't resolve UDP address %s", src)
+	}
+	// spew.Dump(udpAddr, err)
+
+	if !udpAddr.IP.To4().Equal(srcIP) {
+		return false, wrapErrPrint(err, "Resolved UDP address is not %s", src)
+	}
+
+	// resolve 255.255.255.255:67
+	dstAddr, err := net.ResolveUDPAddr("udp4", dst)
+	if err != nil {
+		return false, wrapErrPrint(err, "Couldn't resolve UDP address %s", dst)
+	}
+
+	// bind to 0.0.0.0:68
+	trace("Listening to udp4 %+v", udpAddr)
+	c, err := net.ListenPacket("udp4", src)
+	if c != nil {
+		defer c.Close()
+	}
+	// spew.Dump(c, err)
+	// spew.Printf("net.ListenUDP returned %v, %v\n", c, err)
+	if err != nil {
+		return false, wrapErrPrint(err, "Couldn't listen to %s", src)
+	}
+
+	// send to 255.255.255.255:67
+	n, err = c.WriteTo(packet, dstAddr)
+	// spew.Dump(n, err)
+	if err != nil {
+		return false, wrapErrPrint(err, "Couldn't send a packet to %s", dst)
+	}
+
+	// wait for answer
+	trace("Waiting %v for an answer", defaultDiscoverTime)
+	// TODO: replicate dhclient's behaviour of retrying several times with progressively bigger timeouts
+	b := make([]byte, 1500)
+	c.SetReadDeadline(time.Now().Add(defaultDiscoverTime))
+	n, _, err = c.ReadFrom(b)
+	if isTimeout(err) {
+		// timed out -- no DHCP servers
+		return false, nil
+	}
+	if err != nil {
+		return false, wrapErrPrint(err, "Couldn't receive packet")
+	}
+	if n > 0 {
+		b = b[:n]
+	}
+	// spew.Dump(n, fromAddr, err, b)
+
+	if n < 240 {
+		// packet too small for dhcp
+		return false, wrapErrPrint(err, "got packet that's too small for DHCP")
+	}
+
+	response := dhcp4.Packet(b[:n])
+	if response.HLen() > 16 {
+		// invalid size
+		return false, wrapErrPrint(err, "got malformed packet with HLen() > 16")
+	}
+
+	parsedOptions := response.ParseOptions()
+	_, ok := parsedOptions[dhcp4.OptionDHCPMessageType]
+	if !ok {
+		return false, wrapErrPrint(err, "got malformed packet without DHCP message type")
+	}
+
+	// that's a DHCP server there
+	return true, nil
+}
diff --git a/dhcpd/dhcpd.go b/dhcpd/dhcpd.go
new file mode 100644
index 00000000..8dc500a1
--- /dev/null
+++ b/dhcpd/dhcpd.go
@@ -0,0 +1,389 @@
+package dhcpd
+
+import (
+	"bytes"
+	"fmt"
+	"log"
+	"net"
+	"time"
+
+	"github.com/krolaw/dhcp4"
+)
+
+const defaultDiscoverTime = time.Second * 3
+
+// field ordering is important -- yaml fields will mirror ordering from here
+type Lease struct {
+	hwaddr net.HardwareAddr `json:"mac" yaml:"hwaddr"`
+	ip     net.IP           `json:"ip"`
+	expiry time.Time        `json:"expires"`
+}
+
+// field ordering is important -- yaml fields will mirror ordering from here
+type ServerConfig struct {
+	Enabled       bool   `json:"enabled" yaml:"enabled"`
+	InterfaceName string `json:"interface_name" yaml:"interface_name"` // eth0, en0 and so on
+	GatewayIP     string `json:"gateway_ip" yaml:"gateway_ip"`
+	SubnetMask    string `json:"subnet_mask" yaml:"subnet_mask"`
+	RangeStart    string `json:"range_start" yaml:"range_start"`
+	RangeEnd      string `json:"range_end" yaml:"range_end"`
+	LeaseDuration uint   `json:"lease_duration" yaml:"lease_duration"` // in seconds
+}
+
+type Server struct {
+	conn *filterConn // listening UDP socket
+
+	ipnet *net.IPNet // if interface name changes, this needs to be reset
+
+	// leases
+	leases       []*Lease
+	leaseStart   net.IP        // parsed from config RangeStart
+	leaseStop    net.IP        // parsed from config RangeEnd
+	leaseTime    time.Duration // parsed from config LeaseDuration
+	leaseOptions dhcp4.Options // parsed from config GatewayIP and SubnetMask
+
+	// IP address pool -- if entry is in the pool, then it's attached to a lease
+	IPpool map[[4]byte]net.HardwareAddr
+
+	ServerConfig
+}
+
+// Start will listen on port 67 and serve DHCP requests.
+// Even though config can be nil, it is not optional (at least for now), since there are no default values (yet).
+func (s *Server) Start(config *ServerConfig) error {
+	if config != nil {
+		s.ServerConfig = *config
+	}
+
+	iface, err := net.InterfaceByName(s.InterfaceName)
+	if err != nil {
+		s.closeConn() // in case it was already started
+		return wrapErrPrint(err, "Couldn't find interface by name %s", s.InterfaceName)
+	}
+
+	// get ipv4 address of an interface
+	s.ipnet = getIfaceIPv4(iface)
+	if s.ipnet == nil {
+		s.closeConn() // in case it was already started
+		return wrapErrPrint(err, "Couldn't find IPv4 address of interface %s %+v", s.InterfaceName, iface)
+	}
+
+	if s.LeaseDuration == 0 {
+		s.leaseTime = time.Hour * 2
+		s.LeaseDuration = uint(s.leaseTime.Seconds())
+	} else {
+		s.leaseTime = time.Second * time.Duration(s.LeaseDuration)
+	}
+
+	s.leaseStart, err = parseIPv4(s.RangeStart)
+	if err != nil {
+		s.closeConn() // in case it was already started
+		return wrapErrPrint(err, "Failed to parse range start address %s", s.RangeStart)
+	}
+
+	s.leaseStop, err = parseIPv4(s.RangeEnd)
+	if err != nil {
+		s.closeConn() // in case it was already started
+		return wrapErrPrint(err, "Failed to parse range end address %s", s.RangeEnd)
+	}
+
+	subnet, err := parseIPv4(s.SubnetMask)
+	if err != nil {
+		s.closeConn() // in case it was already started
+		return wrapErrPrint(err, "Failed to parse subnet mask %s", s.SubnetMask)
+	}
+
+	// if !bytes.Equal(subnet, s.ipnet.Mask) {
+	// 	s.closeConn() // in case it was already started
+	// 	return wrapErrPrint(err, "specified subnet mask %s does not meatch interface %s subnet mask %s", s.SubnetMask, s.InterfaceName, s.ipnet.Mask)
+	// }
+
+	router, err := parseIPv4(s.GatewayIP)
+	if err != nil {
+		s.closeConn() // in case it was already started
+		return wrapErrPrint(err, "Failed to parse gateway IP %s", s.GatewayIP)
+	}
+
+	s.leaseOptions = dhcp4.Options{
+		dhcp4.OptionSubnetMask:       subnet,
+		dhcp4.OptionRouter:           router,
+		dhcp4.OptionDomainNameServer: s.ipnet.IP,
+	}
+
+	// TODO: don't close if interface and addresses are the same
+	if s.conn != nil {
+		s.closeConn()
+	}
+
+	c, err := newFilterConn(*iface, ":67") // it has to be bound to 0.0.0.0:67, otherwise it won't see DHCP discover/request packets
+	if err != nil {
+		return wrapErrPrint(err, "Couldn't start listening socket on 0.0.0.0:67")
+	}
+
+	s.conn = c
+
+	go func() {
+		// operate on c instead of c.conn because c.conn can change over time
+		err := dhcp4.Serve(c, s)
+		if err != nil {
+			log.Printf("dhcp4.Serve() returned with error: %s", err)
+		}
+		c.Close() // in case Serve() exits for other reason than listening socket closure
+	}()
+
+	return nil
+}
+
+func (s *Server) Stop() error {
+	if s.conn == nil {
+		// nothing to do, return silently
+		return nil
+	}
+	err := s.closeConn()
+	if err != nil {
+		return wrapErrPrint(err, "Couldn't close UDP listening socket")
+	}
+
+	return nil
+}
+
+// closeConn will close the connection and set it to zero
+func (s *Server) closeConn() error {
+	if s.conn == nil {
+		return nil
+	}
+	err := s.conn.Close()
+	s.conn = nil
+	return err
+}
+
+func (s *Server) reserveLease(p dhcp4.Packet) (*Lease, error) {
+	// WARNING: do not remove copy()
+	// the given hwaddr by p.CHAddr() in the packet survives only during ServeDHCP() call
+	// since we need to retain it we need to make our own copy
+	hwaddrCOW := p.CHAddr()
+	hwaddr := make(net.HardwareAddr, len(hwaddrCOW))
+	copy(hwaddr, hwaddrCOW)
+	foundLease := s.locateLease(p)
+	if foundLease != nil {
+		// trace("found lease for %s: %+v", hwaddr, foundLease)
+		return foundLease, nil
+	}
+	// not assigned a lease, create new one, find IP from LRU
+	trace("Lease not found for %s: creating new one", hwaddr)
+	ip, err := s.findFreeIP(p, hwaddr)
+	if err != nil {
+		return nil, wrapErrPrint(err, "Couldn't find free IP for the lease %s", hwaddr.String())
+	}
+	trace("Assigning to %s IP address %s", hwaddr, ip.String())
+	lease := &Lease{hwaddr: hwaddr, ip: ip}
+	s.leases = append(s.leases, lease)
+	return lease, nil
+}
+
+func (s *Server) locateLease(p dhcp4.Packet) *Lease {
+	hwaddr := p.CHAddr()
+	for i := range s.leases {
+		if bytes.Equal([]byte(hwaddr), []byte(s.leases[i].hwaddr)) {
+			// trace("bytes.Equal(%s, %s) returned true", hwaddr, s.leases[i].hwaddr)
+			return s.leases[i]
+		}
+	}
+	return nil
+}
+
+func (s *Server) findFreeIP(p dhcp4.Packet, hwaddr net.HardwareAddr) (net.IP, error) {
+	// if IP pool is nil, lazy initialize it
+	if s.IPpool == nil {
+		s.IPpool = make(map[[4]byte]net.HardwareAddr)
+	}
+
+	// go from start to end, find unreserved IP
+	var foundIP net.IP
+	for i := 0; i < dhcp4.IPRange(s.leaseStart, s.leaseStop); i++ {
+		newIP := dhcp4.IPAdd(s.leaseStart, i)
+		foundHWaddr := s.getIPpool(newIP)
+		trace("tried IP %v, got hwaddr %v", newIP, foundHWaddr)
+		if foundHWaddr != nil && len(foundHWaddr) != 0 {
+			// if !bytes.Equal(foundHWaddr, hwaddr) {
+			// 	trace("SHOULD NOT HAPPEN: hwaddr in IP pool %s is not equal to hwaddr in lease %s", foundHWaddr, hwaddr)
+			// }
+			trace("will try again")
+			continue
+		}
+		foundIP = newIP
+		break
+	}
+
+	if foundIP == nil {
+		// TODO: LRU
+		return nil, fmt.Errorf("Couldn't find free entry in IP pool")
+	}
+
+	s.reserveIP(foundIP, hwaddr)
+
+	return foundIP, nil
+}
+
+func (s *Server) getIPpool(ip net.IP) net.HardwareAddr {
+	rawIP := []byte(ip)
+	IP4 := [4]byte{rawIP[0], rawIP[1], rawIP[2], rawIP[3]}
+	return s.IPpool[IP4]
+}
+
+func (s *Server) reserveIP(ip net.IP, hwaddr net.HardwareAddr) {
+	rawIP := []byte(ip)
+	IP4 := [4]byte{rawIP[0], rawIP[1], rawIP[2], rawIP[3]}
+	s.IPpool[IP4] = hwaddr
+}
+
+func (s *Server) unreserveIP(ip net.IP) {
+	rawIP := []byte(ip)
+	IP4 := [4]byte{rawIP[0], rawIP[1], rawIP[2], rawIP[3]}
+	delete(s.IPpool, IP4)
+}
+
+func (s *Server) ServeDHCP(p dhcp4.Packet, msgType dhcp4.MessageType, options dhcp4.Options) dhcp4.Packet {
+	trace("Got %v message", msgType)
+	trace("Leases:")
+	for i, lease := range s.leases {
+		trace("Lease #%d: hwaddr %s, ip %s, expiry %s", i, lease.hwaddr, lease.ip, lease.expiry)
+	}
+	trace("IP pool:")
+	for ip, hwaddr := range s.IPpool {
+		trace("IP pool entry %s -> %s", net.IPv4(ip[0], ip[1], ip[2], ip[3]), hwaddr)
+	}
+	// spew.Dump(s.leases, s.IPpool)
+	// log.Printf("Called with msgType = %v, options = %+v", msgType, options)
+	// spew.Dump(p)
+	// log.Printf("%14s %v", "p.Broadcast", p.Broadcast())       // false
+	// log.Printf("%14s %v", "p.CHAddr", p.CHAddr())             // 2c:f0:a2:f2:31:00
+	// log.Printf("%14s %v", "p.CIAddr", p.CIAddr())             // 0.0.0.0
+	// log.Printf("%14s %v", "p.Cookie", p.Cookie())             // [99 130 83 99]
+	// log.Printf("%14s %v", "p.File", p.File())                 // []
+	// log.Printf("%14s %v", "p.Flags", p.Flags())               // [0 0]
+	// log.Printf("%14s %v", "p.GIAddr", p.GIAddr())             // 0.0.0.0
+	// log.Printf("%14s %v", "p.HLen", p.HLen())                 // 6
+	// log.Printf("%14s %v", "p.HType", p.HType())               // 1
+	// log.Printf("%14s %v", "p.Hops", p.Hops())                 // 0
+	// log.Printf("%14s %v", "p.OpCode", p.OpCode())             // BootRequest
+	// log.Printf("%14s %v", "p.Options", p.Options())           // [53 1 1 55 10 1 121 3 6 15 119 252 95 44 46 57 2 5 220 61 7 1 44 240 162 242 49 0 51 4 0 118 167 0 12 4 119 104 109 100 255 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0]
+	// log.Printf("%14s %v", "p.ParseOptions", p.ParseOptions()) // map[OptionParameterRequestList:[1 121 3 6 15 119 252 95 44 46] OptionDHCPMessageType:[1] OptionMaximumDHCPMessageSize:[5 220] OptionClientIdentifier:[1 44 240 162 242 49 0] OptionIPAddressLeaseTime:[0 118 167 0] OptionHostName:[119 104 109 100]]
+	// log.Printf("%14s %v", "p.SIAddr", p.SIAddr())             // 0.0.0.0
+	// log.Printf("%14s %v", "p.SName", p.SName())               // []
+	// log.Printf("%14s %v", "p.Secs", p.Secs())                 // [0 8]
+	// log.Printf("%14s %v", "p.XId", p.XId())                   // [211 184 20 44]
+	// log.Printf("%14s %v", "p.YIAddr", p.YIAddr())             // 0.0.0.0
+
+	switch msgType {
+	case dhcp4.Discover: // Broadcast Packet From Client - Can I have an IP?
+		// find a lease, but don't update lease time
+		trace("Got from client: Discover")
+		lease, err := s.reserveLease(p)
+		if err != nil {
+			trace("Couldn't find free lease: %s", err)
+			// couldn't find lease, don't respond
+			return nil
+		}
+		reply := dhcp4.ReplyPacket(p, dhcp4.Offer, s.ipnet.IP, lease.ip, s.leaseTime, s.leaseOptions.SelectOrderOrAll(options[dhcp4.OptionParameterRequestList]))
+		trace("Replying with offer: offered IP %v for %v with options %+v", lease.ip, s.leaseTime, reply.ParseOptions())
+		return reply
+	case dhcp4.Request: // Broadcast From Client - I'll take that IP (Also start for renewals)
+		// start/renew a lease -- update lease time
+		// some clients (OSX) just go right ahead and do Request first from previously known IP, if they get NAK, they restart full cycle with Discover then Request
+		trace("Got from client: Request")
+		if server, ok := options[dhcp4.OptionServerIdentifier]; ok && !net.IP(server).Equal(s.ipnet.IP) {
+			trace("Request message not for this DHCP server (%v vs %v)", p, server, s.ipnet.IP)
+			return nil // Message not for this dhcp server
+		}
+
+		reqIP := net.IP(options[dhcp4.OptionRequestedIPAddress])
+		if reqIP == nil {
+			reqIP = net.IP(p.CIAddr())
+		}
+
+		if reqIP.To4() == nil {
+			trace("Replying with NAK: request IP isn't valid IPv4: %s", reqIP)
+			return dhcp4.ReplyPacket(p, dhcp4.NAK, s.ipnet.IP, nil, 0, nil)
+		}
+
+		if reqIP.Equal(net.IPv4zero) {
+			trace("Replying with NAK: request IP is 0.0.0.0")
+			return dhcp4.ReplyPacket(p, dhcp4.NAK, s.ipnet.IP, nil, 0, nil)
+		}
+
+		trace("requested IP is %s", reqIP)
+		lease, err := s.reserveLease(p)
+		if err != nil {
+			trace("Couldn't find free lease: %s", err)
+			// couldn't find lease, don't respond
+			return nil
+		}
+
+		if lease.ip.Equal(reqIP) {
+			// IP matches lease IP, nothing else to do
+			lease.expiry = time.Now().Add(s.leaseTime)
+			trace("Replying with ACK: request IP matches lease IP, nothing else to do. IP %v for %v", lease.ip, p.CHAddr())
+			return dhcp4.ReplyPacket(p, dhcp4.ACK, s.ipnet.IP, lease.ip, s.leaseTime, s.leaseOptions.SelectOrderOrAll(options[dhcp4.OptionParameterRequestList]))
+		}
+
+		//
+		// requested IP different from lease
+		//
+
+		trace("lease IP is different from requested IP: %s vs %s", lease.ip, reqIP)
+
+		hwaddr := s.getIPpool(reqIP)
+		if hwaddr == nil {
+			// not in pool, check if it's in DHCP range
+			if dhcp4.IPInRange(s.leaseStart, s.leaseStop, reqIP) {
+				// okay, we can give it to our client -- it's in our DHCP range and not taken, so let them use their IP
+				trace("Replying with ACK: request IP %v is not taken, so assigning lease IP %v to it, for %v", reqIP, lease.ip, p.CHAddr())
+				s.unreserveIP(lease.ip)
+				lease.ip = reqIP
+				s.reserveIP(reqIP, p.CHAddr())
+				lease.expiry = time.Now().Add(s.leaseTime)
+				return dhcp4.ReplyPacket(p, dhcp4.ACK, s.ipnet.IP, lease.ip, s.leaseTime, s.leaseOptions.SelectOrderOrAll(options[dhcp4.OptionParameterRequestList]))
+			}
+		}
+
+		if hwaddr != nil && !bytes.Equal(hwaddr, lease.hwaddr) {
+			log.Printf("SHOULD NOT HAPPEN: IP pool hwaddr does not match lease hwaddr: %s vs %s", hwaddr, lease.hwaddr)
+		}
+
+		// requsted IP is not sufficient, reply with NAK
+		if hwaddr != nil {
+			trace("Replying with NAK: request IP %s is taken, asked by %v", reqIP, p.CHAddr())
+			return dhcp4.ReplyPacket(p, dhcp4.NAK, s.ipnet.IP, nil, 0, nil)
+		}
+
+		// requested IP is outside of DHCP range
+		trace("Replying with NAK: request IP %s is outside of DHCP range [%s, %s], asked by %v", reqIP, s.leaseStart, s.leaseStop, p.CHAddr())
+		return dhcp4.ReplyPacket(p, dhcp4.NAK, s.ipnet.IP, nil, 0, nil)
+	case dhcp4.Decline: // Broadcast From Client - Sorry I can't use that IP
+		trace("Got from client: Decline")
+
+	case dhcp4.Release: // From Client, I don't need that IP anymore
+		trace("Got from client: Release")
+
+	case dhcp4.Inform: // From Client, I have this IP and there's nothing you can do about it
+		trace("Got from client: Inform")
+		// do nothing
+
+	// from server -- ignore those but enumerate just in case
+	case dhcp4.Offer: // Broadcast From Server - Here's an IP
+		log.Printf("SHOULD NOT HAPPEN -- FROM ANOTHER DHCP SERVER: Offer")
+	case dhcp4.ACK: // From Server, Yes you can have that IP
+		log.Printf("SHOULD NOT HAPPEN -- FROM ANOTHER DHCP SERVER: ACK")
+	case dhcp4.NAK: // From Server, No you cannot have that IP
+		log.Printf("SHOULD NOT HAPPEN -- FROM ANOTHER DHCP SERVER: NAK")
+	default:
+		log.Printf("Unknown DHCP packet detected, ignoring: %v", msgType)
+		return nil
+	}
+	return nil
+}
+
+func (s *Server) Leases() []*Lease {
+	return s.leases
+}
diff --git a/dhcpd/filter_conn.go b/dhcpd/filter_conn.go
new file mode 100644
index 00000000..cd943ab1
--- /dev/null
+++ b/dhcpd/filter_conn.go
@@ -0,0 +1,62 @@
+package dhcpd
+
+import (
+	"net"
+
+	"github.com/joomcode/errorx"
+	"golang.org/x/net/ipv4"
+)
+
+// TODO: on windows, controlmessage does not work, try to find out another way
+// https://github.com/golang/net/blob/master/ipv4/payload.go#L13
+
+type filterConn struct {
+	iface net.Interface
+	conn  *ipv4.PacketConn
+	// cm    *ipv4.ControlMessage
+}
+
+func newFilterConn(iface net.Interface, address string) (*filterConn, error) {
+	c, err := net.ListenPacket("udp4", address)
+	if err != nil {
+		return nil, errorx.Decorate(err, "Couldn't listen to %s on UDP4", address)
+	}
+
+	p := ipv4.NewPacketConn(c)
+	err = p.SetControlMessage(ipv4.FlagInterface, true)
+	if err != nil {
+		c.Close()
+		return nil, errorx.Decorate(err, "Couldn't set control message FlagInterface on connection")
+	}
+
+	return &filterConn{iface: iface, conn: p}, nil
+}
+
+func (f *filterConn) ReadFrom(b []byte) (int, net.Addr, error) {
+	for { // read until we find a suitable packet
+		n, cm, addr, err := f.conn.ReadFrom(b)
+		if err != nil {
+			return 0, addr, errorx.Decorate(err, "Error when reading from socket")
+		}
+		if cm == nil {
+			// no controlmessage was passed, so pass the packet to the caller
+			return n, addr, nil
+		}
+		if cm.IfIndex == f.iface.Index {
+			return n, addr, nil
+		}
+		// packet doesn't match criteria, drop it
+	}
+	return 0, nil, nil
+}
+
+func (f *filterConn) WriteTo(b []byte, addr net.Addr) (int, error) {
+	cm := ipv4.ControlMessage{
+		IfIndex: f.iface.Index,
+	}
+	return f.conn.WriteTo(b, &cm, addr)
+}
+
+func (f *filterConn) Close() error {
+	return f.conn.Close()
+}
diff --git a/dhcpd/helpers.go b/dhcpd/helpers.go
new file mode 100644
index 00000000..cf721f31
--- /dev/null
+++ b/dhcpd/helpers.go
@@ -0,0 +1,101 @@
+package dhcpd
+
+import (
+	"fmt"
+	"log"
+	"net"
+	"os"
+	"path"
+	"runtime"
+	"strings"
+
+	"github.com/joomcode/errorx"
+)
+
+func trace(format string, args ...interface{}) {
+	pc := make([]uintptr, 10) // at least 1 entry needed
+	runtime.Callers(2, pc)
+	f := runtime.FuncForPC(pc[0])
+	var buf strings.Builder
+	buf.WriteString(fmt.Sprintf("%s(): ", path.Base(f.Name())))
+	text := fmt.Sprintf(format, args...)
+	buf.WriteString(text)
+	if len(text) == 0 || text[len(text)-1] != '\n' {
+		buf.WriteRune('\n')
+	}
+	fmt.Fprint(os.Stderr, buf.String())
+}
+
+func isTimeout(err error) bool {
+	operr, ok := err.(*net.OpError)
+	if !ok {
+		return false
+	}
+	return operr.Timeout()
+}
+
+// return first IPv4 address of an interface, if there is any
+func getIfaceIPv4(iface *net.Interface) *net.IPNet {
+	ifaceAddrs, err := iface.Addrs()
+	if err != nil {
+		panic(err)
+	}
+
+	for _, addr := range ifaceAddrs {
+		ipnet, ok := addr.(*net.IPNet)
+		if !ok {
+			// not an IPNet, should not happen
+			log.Fatalf("SHOULD NOT HAPPEN: got iface.Addrs() element %s that is not net.IPNet", addr)
+		}
+
+		if ipnet.IP.To4() == nil {
+			log.Printf("Got IP that is not IPv4: %v", ipnet.IP)
+			continue
+		}
+
+		log.Printf("Got IP that is IPv4: %v", ipnet.IP)
+		return &net.IPNet{
+			IP:   ipnet.IP.To4(),
+			Mask: ipnet.Mask,
+		}
+	}
+	return nil
+}
+
+func isConnClosed(err error) bool {
+	if err == nil {
+		return false
+	}
+	nerr, ok := err.(*net.OpError)
+	if !ok {
+		return false
+	}
+
+	if strings.Contains(nerr.Err.Error(), "use of closed network connection") {
+		return true
+	}
+
+	return false
+}
+
+func wrapErrPrint(err error, message string, args ...interface{}) error {
+	var errx error
+	if err == nil {
+		errx = fmt.Errorf(message, args...)
+	} else {
+		errx = errorx.Decorate(err, message, args...)
+	}
+	log.Println(errx.Error())
+	return errx
+}
+
+func parseIPv4(text string) (net.IP, error) {
+	result := net.ParseIP(text)
+	if result == nil {
+		return nil, fmt.Errorf("%s is not an IP address", text)
+	}
+	if result.To4() == nil {
+		return nil, fmt.Errorf("%s is not an IPv4 address", text)
+	}
+	return result.To4(), nil
+}
diff --git a/dhcpd/standalone/main.go b/dhcpd/standalone/main.go
new file mode 100644
index 00000000..f6dac3ac
--- /dev/null
+++ b/dhcpd/standalone/main.go
@@ -0,0 +1,111 @@
+package main
+
+import (
+	"log"
+	"net"
+	"os"
+	"os/signal"
+	"syscall"
+	"time"
+
+	"github.com/AdguardTeam/AdGuardHome/dhcpd"
+	"github.com/krolaw/dhcp4"
+)
+
+func main() {
+	if len(os.Args) < 2 {
+		log.Printf("Usage: %s <interface name>", os.Args[0])
+		os.Exit(64)
+	}
+
+	ifaceName := os.Args[1]
+	present, err := dhcpd.CheckIfOtherDHCPServersPresent(ifaceName)
+	if err != nil {
+		panic(err)
+	}
+	log.Printf("Found DHCP server? %v", present)
+	if present {
+		log.Printf("Will not start DHCP server because there's already running one on the network")
+		os.Exit(1)
+	}
+
+	iface, err := net.InterfaceByName(ifaceName)
+	if err != nil {
+		panic(err)
+	}
+
+	// get ipv4 address of an interface
+	ifaceIPNet := getIfaceIPv4(iface)
+	if ifaceIPNet == nil {
+		panic(err)
+	}
+
+	// append 10 to server's IP address as start
+	start := dhcp4.IPAdd(ifaceIPNet.IP, 10)
+	// lease range is 100 IP's, but TODO: don't go beyond end of subnet mask
+	stop := dhcp4.IPAdd(start, 100)
+
+	server := dhcpd.Server{}
+	config := dhcpd.ServerConfig{
+		InterfaceName: ifaceName,
+		RangeStart:    start.String(),
+		RangeEnd:      stop.String(),
+		SubnetMask:    "255.255.255.0",
+		GatewayIP:     "192.168.7.1",
+	}
+	log.Printf("Starting DHCP server")
+	err = server.Start(&config)
+	if err != nil {
+		panic(err)
+	}
+
+	time.Sleep(time.Second)
+	log.Printf("Stopping DHCP server")
+	err = server.Stop()
+	if err != nil {
+		panic(err)
+	}
+	log.Printf("Starting DHCP server")
+	err = server.Start(&config)
+	if err != nil {
+		panic(err)
+	}
+	log.Printf("Starting DHCP server while it's already running")
+	err = server.Start(&config)
+	if err != nil {
+		panic(err)
+	}
+	log.Printf("Now serving DHCP")
+	signal_channel := make(chan os.Signal)
+	signal.Notify(signal_channel, syscall.SIGINT, syscall.SIGTERM)
+	<-signal_channel
+
+}
+
+// return first IPv4 address of an interface, if there is any
+func getIfaceIPv4(iface *net.Interface) *net.IPNet {
+	ifaceAddrs, err := iface.Addrs()
+	if err != nil {
+		panic(err)
+	}
+
+	for _, addr := range ifaceAddrs {
+		ipnet, ok := addr.(*net.IPNet)
+		if !ok {
+			// not an IPNet, should not happen
+			log.Fatalf("SHOULD NOT HAPPEN: got iface.Addrs() element %s that is not net.IPNet", addr)
+		}
+
+		if ipnet.IP.To4() == nil {
+			log.Printf("Got IP that is not IPv4: %v", ipnet.IP)
+			continue
+		}
+
+		log.Printf("Got IP that is IPv4: %v", ipnet.IP)
+		return &net.IPNet{
+			IP:   ipnet.IP.To4(),
+			Mask: ipnet.Mask,
+		}
+	}
+	return nil
+}
diff --git a/go.mod b/go.mod
index 36ddb6a2..bdc4c0d3 100644
--- a/go.mod
+++ b/go.mod
@@ -6,11 +6,13 @@ require (
 	github.com/ameshkov/dnscrypt v1.0.1
 	github.com/beefsack/go-rate v0.0.0-20180408011153-efa7637bb9b6
 	github.com/bluele/gcache v0.0.0-20171010155617-472614239ac7
+	github.com/davecgh/go-spew v1.1.1
 	github.com/go-ole/go-ole v1.2.1 // indirect
 	github.com/go-test/deep v1.0.1
 	github.com/gobuffalo/packr v1.19.0
 	github.com/jedisct1/go-dnsstamps v0.0.0-20180418170050-1e4999280f86
 	github.com/joomcode/errorx v0.1.0
+	github.com/krolaw/dhcp4 v0.0.0-20180925202202-7cead472c414
 	github.com/miekg/dns v1.1.1
 	github.com/patrickmn/go-cache v2.1.0+incompatible
 	github.com/pkg/errors v0.8.0
diff --git a/go.sum b/go.sum
index 1a32fbbb..4c18efc7 100644
--- a/go.sum
+++ b/go.sum
@@ -45,6 +45,8 @@ github.com/joomcode/errorx v0.1.0 h1:QmJMiI1DE1UFje2aI1ZWO/VMT5a32qBoXUclGOt8vsc
 github.com/joomcode/errorx v0.1.0/go.mod h1:kgco15ekB6cs+4Xjzo7SPeXzx38PbJzBwbnu9qfVNHQ=
 github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk=
 github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
+github.com/krolaw/dhcp4 v0.0.0-20180925202202-7cead472c414 h1:6wnYc2S/lVM7BvR32BM74ph7bPgqMztWopMYKgVyEho=
+github.com/krolaw/dhcp4 v0.0.0-20180925202202-7cead472c414/go.mod h1:0AqAH3ZogsCrvrtUpvc6EtVKbc3w6xwZhkvGLuqyi3o=
 github.com/markbates/oncer v0.0.0-20181014194634-05fccaae8fc4 h1:Mlji5gkcpzkqTROyE4ZxZ8hN7osunMb2RuGVrbvMvCc=
 github.com/markbates/oncer v0.0.0-20181014194634-05fccaae8fc4/go.mod h1:Ld9puTsIW75CHf65OeIOkyKbteujpZVXDpWK6YGZbxE=
 github.com/miekg/dns v1.1.1 h1:DVkblRdiScEnEr0LR9nTnEQqHYycjkXW9bOjd+2EL2o=