From 9fe6af684f21a5588da8d2c19f5b7aafa59a3f19 Mon Sep 17 00:00:00 2001 From: Cuong Manh Le Date: Wed, 21 Jun 2023 23:40:10 +0700 Subject: [PATCH] all: watch lease files if send client info enabled So users who run ctrld in Linux can still see clients info, even though it's not an router platform that ctrld supports. --- client_info.go | 8 + cmd/ctrld/cli.go | 3 - cmd/ctrld/dns_proxy.go | 5 +- cmd/ctrld/prog.go | 12 + internal/clientinfo/client_info.go | 219 ++++++++++++++++++ .../client_info_test.go | 18 +- internal/router/client_info.go | 194 ---------------- internal/router/router.go | 14 -- 8 files changed, 250 insertions(+), 223 deletions(-) create mode 100644 internal/clientinfo/client_info.go rename internal/{router => clientinfo}/client_info_test.go (88%) delete mode 100644 internal/router/client_info.go diff --git a/client_info.go b/client_info.go index d0d993a..acd7c5e 100644 --- a/client_info.go +++ b/client_info.go @@ -9,3 +9,11 @@ type ClientInfo struct { IP string Hostname string } + +// LeaseFileFormat specifies the format of DHCP lease file. +type LeaseFileFormat string + +const ( + Dnsmasq LeaseFileFormat = "dnsmasq" + IscDhcpd = "isc-dhcpd" +) diff --git a/cmd/ctrld/cli.go b/cmd/ctrld/cli.go index dc80ebd..36b38e2 100644 --- a/cmd/ctrld/cli.go +++ b/cmd/ctrld/cli.go @@ -219,9 +219,6 @@ func initCLI() { if err := router.Cleanup(svcConfig); err != nil { mainLog.Error().Err(err).Msg("could not cleanup router") } - if err := router.Stop(); err != nil { - mainLog.Error().Err(err).Msg("problem occurred while stopping router") - } p.resetDNS() }) } diff --git a/cmd/ctrld/dns_proxy.go b/cmd/ctrld/dns_proxy.go index 970d52e..38a0ba4 100644 --- a/cmd/ctrld/dns_proxy.go +++ b/cmd/ctrld/dns_proxy.go @@ -22,7 +22,6 @@ import ( "github.com/Control-D-Inc/ctrld" "github.com/Control-D-Inc/ctrld/internal/dnscache" ctrldnet "github.com/Control-D-Inc/ctrld/internal/net" - "github.com/Control-D-Inc/ctrld/internal/router" ) const ( @@ -56,7 +55,7 @@ func (p *prog) serveDNS(listenerNum string) error { q := m.Question[0] domain := canonicalName(q.Name) reqId := requestID() - remoteAddr := spoofRemoteAddr(w.RemoteAddr(), router.GetClientInfoByMac(macFromMsg(m))) + remoteAddr := spoofRemoteAddr(w.RemoteAddr(), p.mt.GetClientInfoByMac(macFromMsg(m))) fmtSrcToDest := fmtRemoteToLocal(listenerNum, remoteAddr.String(), w.LocalAddr().String()) t := time.Now() ctx := context.WithValue(context.Background(), ctrld.ReqIdCtxKey{}, reqId) @@ -247,7 +246,7 @@ func (p *prog) proxy(ctx context.Context, upstreams []string, failoverRcodes []i } resolve := func(n int, upstreamConfig *ctrld.UpstreamConfig, msg *dns.Msg) *dns.Msg { if upstreamConfig.UpstreamSendClientInfo() { - ci := router.GetClientInfoByMac(macFromMsg(msg)) + ci := p.mt.GetClientInfoByMac(macFromMsg(msg)) if ci != nil { ctrld.Log(ctx, mainLog.Debug(), "including client info with the request") ctx = context.WithValue(ctx, ctrld.ClientInfoCtxKey{}, ci) diff --git a/cmd/ctrld/prog.go b/cmd/ctrld/prog.go index 5e563bf..fcdfc8d 100644 --- a/cmd/ctrld/prog.go +++ b/cmd/ctrld/prog.go @@ -13,6 +13,7 @@ import ( "github.com/kardianos/service" "github.com/Control-D-Inc/ctrld" + "github.com/Control-D-Inc/ctrld/internal/clientinfo" "github.com/Control-D-Inc/ctrld/internal/dnscache" "github.com/Control-D-Inc/ctrld/internal/router" ) @@ -39,6 +40,7 @@ type prog struct { cfg *ctrld.Config cache dnscache.Cacher sema semaphore + mt *clientinfo.MacTable started chan struct{} onStarted []func() @@ -100,6 +102,16 @@ func (p *prog) run() { go uc.Ping() } + p.mt = clientinfo.NewMacTable() + if p.cfg.HasUpstreamSendClientInfo() { + mainLog.Debug().Msg("Sending client info enabled") + if err := p.mt.Init(); err == nil { + mainLog.Debug().Msg("Start watching client info changes") + go p.mt.WatchLeaseFiles() + } else { + mainLog.Warn().Err(err).Msg("could not record client info") + } + } go p.watchLinkState() for listenerNum := range p.cfg.Listener { diff --git a/internal/clientinfo/client_info.go b/internal/clientinfo/client_info.go new file mode 100644 index 0000000..48b8678 --- /dev/null +++ b/internal/clientinfo/client_info.go @@ -0,0 +1,219 @@ +package clientinfo + +import ( + "bufio" + "bytes" + "fmt" + "io" + "log" + "net" + "os" + "strings" + "sync" + "time" + + "github.com/fsnotify/fsnotify" + "tailscale.com/util/lineread" + + "github.com/Control-D-Inc/ctrld" +) + +// clientInfoFiles specifies client info files and how to read them on supported platforms. +var clientInfoFiles = map[string]ctrld.LeaseFileFormat{ + "/tmp/dnsmasq.leases": ctrld.Dnsmasq, // ddwrt + "/tmp/dhcp.leases": ctrld.Dnsmasq, // openwrt + "/var/lib/misc/dnsmasq.leases": ctrld.Dnsmasq, // merlin + "/mnt/data/udapi-config/dnsmasq.lease": ctrld.Dnsmasq, // UDM Pro + "/data/udapi-config/dnsmasq.lease": ctrld.Dnsmasq, // UDR + "/etc/dhcpd/dhcpd-leases.log": ctrld.Dnsmasq, // Synology + "/tmp/var/lib/misc/dnsmasq.leases": ctrld.Dnsmasq, // Tomato + "/run/dnsmasq-dhcp.leases": ctrld.Dnsmasq, // EdgeOS + "/run/dhcpd.leases": ctrld.IscDhcpd, // EdgeOS + "/var/dhcpd/var/db/dhcpd.leases": ctrld.IscDhcpd, // Pfsense + "/home/pi/.router/run/dhcp/dnsmasq.leases": ctrld.Dnsmasq, // Firewalla +} + +// NewMacTable returns new Mac table to record client information. +func NewMacTable() *MacTable { + return &MacTable{} +} + +// MacTable records clients information by MAC address. +type MacTable struct { + mac sync.Map + watcher *fsnotify.Watcher +} + +// Init initializes recording client info. +func (mt *MacTable) Init() error { + watcher, err := fsnotify.NewWatcher() + if err != nil { + return err + } + mt.watcher = watcher + for file, format := range clientInfoFiles { + // Ignore errors for default lease files. + _ = mt.AddLeaseFile(file, format) + } + return nil +} + +// AddLeaseFile adds given lease file for reading/watching clients info. +func (mt *MacTable) AddLeaseFile(name string, format ctrld.LeaseFileFormat) error { + if err := mt.readLeaseFile(name, format); err != nil { + return fmt.Errorf("could not read lease file: %w", err) + } + clientInfoFiles[name] = format + return mt.watcher.Add(name) +} + +// GetClientInfoByMac returns ClientInfo for the client associated with the given MAC address. +func (mt *MacTable) GetClientInfoByMac(mac string) *ctrld.ClientInfo { + if mac == "" { + return nil + } + val, ok := mt.mac.Load(mac) + if !ok { + return nil + } + return val.(*ctrld.ClientInfo) +} + +// WatchLeaseFiles watches changes happens in dnsmasq/dhcpd +// lease files, perform updating to mac table if necessary. +func (mt *MacTable) WatchLeaseFiles() { + if mt.watcher == nil { + return + } + timer := time.NewTicker(time.Minute * 5) + for { + select { + case <-timer.C: + for _, name := range mt.watcher.WatchList() { + format := clientInfoFiles[name] + if err := mt.readLeaseFile(name, format); err != nil { + ctrld.ProxyLog.Err(err).Str("file", name).Msg("failed to update lease file") + } + } + case event, ok := <-mt.watcher.Events: + if !ok { + return + } + if event.Has(fsnotify.Write) { + format := clientInfoFiles[event.Name] + if err := mt.readLeaseFile(event.Name, format); err != nil && !os.IsNotExist(err) { + ctrld.ProxyLog.Err(err).Str("file", event.Name).Msg("leases file changed but failed to update client info") + } + } + case err, ok := <-mt.watcher.Errors: + if !ok { + return + } + ctrld.ProxyLog.Err(err).Msg("could not watch client info file") + } + } +} + +// readLeaseFile reads the lease file with given format, saving client information to mac table. +func (mt *MacTable) readLeaseFile(name string, format ctrld.LeaseFileFormat) error { + switch format { + case ctrld.Dnsmasq: + return mt.dnsmasqReadClientInfoFile(name) + case ctrld.IscDhcpd: + return mt.iscDHCPReadClientInfoFile(name) + } + return fmt.Errorf("unsupported format: %s, file: %s", format, name) +} + +// dnsmasqReadClientInfoFile populates mac table with client info reading from dnsmasq lease file. +func (mt *MacTable) dnsmasqReadClientInfoFile(name string) error { + f, err := os.Open(name) + if err != nil { + return err + } + defer f.Close() + return mt.dnsmasqReadClientInfoReader(f) + +} + +// dnsmasqReadClientInfoReader likes ctrld.Dnsmasq, but reading from an io.Reader instead of file. +func (mt *MacTable) dnsmasqReadClientInfoReader(reader io.Reader) error { + return lineread.Reader(reader, func(line []byte) error { + fields := bytes.Fields(line) + if len(fields) < 4 { + return nil + } + mac := string(fields[1]) + if _, err := net.ParseMAC(mac); err != nil { + // The second field is not a mac, skip. + return nil + } + ip := normalizeIP(string(fields[2])) + if net.ParseIP(ip) == nil { + log.Printf("invalid ip address entry: %q", ip) + ip = "" + } + hostname := string(fields[3]) + mt.mac.Store(mac, &ctrld.ClientInfo{Mac: mac, IP: ip, Hostname: hostname}) + return nil + }) +} + +// iscDHCPReadClientInfoFile populates mac table with client info reading from isc-dhcpd lease file. +func (mt *MacTable) iscDHCPReadClientInfoFile(name string) error { + f, err := os.Open(name) + if err != nil { + return err + } + defer f.Close() + return mt.iscDHCPReadClientInfoReader(f) +} + +// iscDHCPReadClientInfoReader likes ctrld.IscDhcpd, but reading from an io.Reader instead of file. +func (mt *MacTable) iscDHCPReadClientInfoReader(reader io.Reader) error { + s := bufio.NewScanner(reader) + var ip, mac, hostname string + for s.Scan() { + line := s.Text() + if strings.HasPrefix(line, "}") { + if mac != "" { + mt.mac.Store(mac, &ctrld.ClientInfo{Mac: mac, IP: ip, Hostname: hostname}) + ip, mac, hostname = "", "", "" + } + continue + } + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + switch fields[0] { + case "lease": + ip = normalizeIP(strings.ToLower(fields[1])) + if net.ParseIP(ip) == nil { + log.Printf("invalid ip address entry: %q", ip) + ip = "" + } + case "hardware": + if len(fields) >= 3 { + mac = strings.ToLower(strings.TrimRight(fields[2], ";")) + if _, err := net.ParseMAC(mac); err != nil { + // Invalid mac, skip. + mac = "" + } + } + case "client-hostname": + hostname = strings.Trim(fields[1], `";`) + } + } + return nil +} + +// normalizeIP normalizes the ip parsed from dnsmasq/dhcpd lease file. +func normalizeIP(in string) string { + // dnsmasq may put ip with interface index in lease file, strip it here. + ip, _, found := strings.Cut(in, "%") + if found { + return ip + } + return in +} diff --git a/internal/router/client_info_test.go b/internal/clientinfo/client_info_test.go similarity index 88% rename from internal/router/client_info_test.go rename to internal/clientinfo/client_info_test.go index fac801c..2f2e092 100644 --- a/internal/router/client_info_test.go +++ b/internal/clientinfo/client_info_test.go @@ -1,4 +1,4 @@ -package router +package clientinfo import ( "io" @@ -31,6 +31,7 @@ func Test_normalizeIP(t *testing.T) { } func Test_readClientInfoReader(t *testing.T) { + mt := NewMacTable() tests := []struct { name string in string @@ -41,7 +42,7 @@ func Test_readClientInfoReader(t *testing.T) { "good dnsmasq", `1683329857 e6:20:59:b8:c1:6d 192.168.1.186 * 01:e6:20:59:b8:c1:6d `, - dnsmasqReadClientInfoReader, + mt.dnsmasqReadClientInfoReader, "e6:20:59:b8:c1:6d", }, { @@ -50,7 +51,7 @@ func Test_readClientInfoReader(t *testing.T) { duid 00:01:00:01:2b:e4:2e:2c:52:52:14:26:dc:1c 1683322985 117442354 2600:4040:b0e6:b700::111 ASDASD 00:01:00:01:2a:d0:b9:81:00:07:32:4c:1c:07 `, - dnsmasqReadClientInfoReader, + mt.dnsmasqReadClientInfoReader, "e6:20:59:b8:c1:6e", }, { @@ -60,7 +61,7 @@ duid 00:01:00:01:2b:e4:2e:2c:52:52:14:26:dc:1c client-hostname "host-1"; } `, - iscDHCPReadClientInfoReader, + mt.iscDHCPReadClientInfoReader, "00:00:00:00:00:01", }, { @@ -75,25 +76,24 @@ lease 192.168.1.2 { client-hostname "host-2"; } `, - iscDHCPReadClientInfoReader, + mt.iscDHCPReadClientInfoReader, "00:00:00:00:00:02", }, { "", `1685794060 00:00:00:00:00:04 192.168.0.209 cuonglm-ThinkPad-X1-Carbon-Gen-9 00:00:00:00:00:04 9`, - dnsmasqReadClientInfoReader, + mt.dnsmasqReadClientInfoReader, "00:00:00:00:00:04", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - r := routerPlatform.Load() - r.mac.Delete(tc.mac) + mt.mac.Delete(tc.mac) if err := tc.readFunc(strings.NewReader(tc.in)); err != nil { t.Errorf("readClientInfoReader() error = %v", err) } - info, existed := r.mac.Load(tc.mac) + info, existed := mt.mac.Load(tc.mac) if !existed { t.Error("client info missing") } diff --git a/internal/router/client_info.go b/internal/router/client_info.go deleted file mode 100644 index c708b75..0000000 --- a/internal/router/client_info.go +++ /dev/null @@ -1,194 +0,0 @@ -package router - -import ( - "bufio" - "bytes" - "io" - "log" - "net" - "os" - "strings" - "time" - - "github.com/fsnotify/fsnotify" - "tailscale.com/util/lineread" - - "github.com/Control-D-Inc/ctrld" -) - -// readClientInfoFunc represents the function for reading client info. -type readClientInfoFunc func(name string) error - -// clientInfoFiles specifies client info files and how to read them on supported platforms. -var clientInfoFiles = map[string]readClientInfoFunc{ - "/tmp/dnsmasq.leases": dnsmasqReadClientInfoFile, // ddwrt - "/tmp/dhcp.leases": dnsmasqReadClientInfoFile, // openwrt - "/var/lib/misc/dnsmasq.leases": dnsmasqReadClientInfoFile, // merlin - "/mnt/data/udapi-config/dnsmasq.lease": dnsmasqReadClientInfoFile, // UDM Pro - "/data/udapi-config/dnsmasq.lease": dnsmasqReadClientInfoFile, // UDR - "/etc/dhcpd/dhcpd-leases.log": dnsmasqReadClientInfoFile, // Synology - "/tmp/var/lib/misc/dnsmasq.leases": dnsmasqReadClientInfoFile, // Tomato - "/run/dnsmasq-dhcp.leases": dnsmasqReadClientInfoFile, // EdgeOS - "/run/dhcpd.leases": iscDHCPReadClientInfoFile, // EdgeOS - "/var/dhcpd/var/db/dhcpd.leases": iscDHCPReadClientInfoFile, // Pfsense - "/home/pi/.router/run/dhcp/dnsmasq.leases": dnsmasqReadClientInfoFile, // Firewalla -} - -// watchClientInfoTable watches changes happens in dnsmasq/dhcpd -// lease files, perform updating to mac table if necessary. -func (r *router) watchClientInfoTable() { - if r.watcher == nil { - return - } - timer := time.NewTicker(time.Minute * 5) - for { - select { - case <-timer.C: - for _, name := range r.watcher.WatchList() { - _ = clientInfoFiles[name](name) - } - case event, ok := <-r.watcher.Events: - if !ok { - return - } - if event.Has(fsnotify.Write) { - readFunc := clientInfoFiles[event.Name] - if readFunc == nil { - log.Println("unknown file format:", event.Name) - continue - } - if err := readFunc(event.Name); err != nil && !os.IsNotExist(err) { - log.Println("could not read client info file:", err) - } - } - case err, ok := <-r.watcher.Errors: - if !ok { - return - } - log.Println("error:", err) - } - } -} - -// Stop performs tasks need to be done before the router stopped. -func Stop() error { - if Name() == "" { - return nil - } - r := routerPlatform.Load() - if r.watcher != nil { - if err := r.watcher.Close(); err != nil { - return err - } - } - return nil -} - -// GetClientInfoByMac returns ClientInfo for the client associated with the given mac. -func GetClientInfoByMac(mac string) *ctrld.ClientInfo { - if mac == "" { - return nil - } - _ = Name() - r := routerPlatform.Load() - val, ok := r.mac.Load(mac) - if !ok { - return nil - } - return val.(*ctrld.ClientInfo) -} - -// dnsmasqReadClientInfoFile populates mac table with client info reading from dnsmasq lease file. -func dnsmasqReadClientInfoFile(name string) error { - f, err := os.Open(name) - if err != nil { - return err - } - defer f.Close() - return dnsmasqReadClientInfoReader(f) - -} - -// dnsmasqReadClientInfoReader likes dnsmasqReadClientInfoFile, but reading from an io.Reader instead of file. -func dnsmasqReadClientInfoReader(reader io.Reader) error { - r := routerPlatform.Load() - return lineread.Reader(reader, func(line []byte) error { - fields := bytes.Fields(line) - if len(fields) < 4 { - return nil - } - mac := string(fields[1]) - if _, err := net.ParseMAC(mac); err != nil { - // The second field is not a mac, skip. - return nil - } - ip := normalizeIP(string(fields[2])) - if net.ParseIP(ip) == nil { - log.Printf("invalid ip address entry: %q", ip) - ip = "" - } - hostname := string(fields[3]) - r.mac.Store(mac, &ctrld.ClientInfo{Mac: mac, IP: ip, Hostname: hostname}) - return nil - }) -} - -// iscDHCPReadClientInfoFile populates mac table with client info reading from isc-dhcpd lease file. -func iscDHCPReadClientInfoFile(name string) error { - f, err := os.Open(name) - if err != nil { - return err - } - defer f.Close() - return iscDHCPReadClientInfoReader(f) -} - -// iscDHCPReadClientInfoReader likes iscDHCPReadClientInfoFile, but reading from an io.Reader instead of file. -func iscDHCPReadClientInfoReader(reader io.Reader) error { - r := routerPlatform.Load() - s := bufio.NewScanner(reader) - var ip, mac, hostname string - for s.Scan() { - line := s.Text() - if strings.HasPrefix(line, "}") { - if mac != "" { - r.mac.Store(mac, &ctrld.ClientInfo{Mac: mac, IP: ip, Hostname: hostname}) - ip, mac, hostname = "", "", "" - } - continue - } - fields := strings.Fields(line) - if len(fields) < 2 { - continue - } - switch fields[0] { - case "lease": - ip = normalizeIP(strings.ToLower(fields[1])) - if net.ParseIP(ip) == nil { - log.Printf("invalid ip address entry: %q", ip) - ip = "" - } - case "hardware": - if len(fields) >= 3 { - mac = strings.ToLower(strings.TrimRight(fields[2], ";")) - if _, err := net.ParseMAC(mac); err != nil { - // Invalid mac, skip. - mac = "" - } - } - case "client-hostname": - hostname = strings.Trim(fields[1], `";`) - } - } - return nil -} - -// normalizeIP normalizes the ip parsed from dnsmasq/dhcpd lease file. -func normalizeIP(in string) string { - // dnsmasq may put ip with interface index in lease file, strip it here. - ip, _, found := strings.Cut(in, "%") - if found { - return ip - } - return in -} diff --git a/internal/router/router.go b/internal/router/router.go index 153f97e..79f5901 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -7,11 +7,9 @@ import ( "fmt" "os" "os/exec" - "sync" "sync/atomic" "time" - "github.com/fsnotify/fsnotify" "github.com/kardianos/service" "tailscale.com/logtail/backoff" @@ -38,8 +36,6 @@ var routerPlatform atomic.Pointer[router] type router struct { name string sendClientInfo bool - mac sync.Map - watcher *fsnotify.Watcher } // IsSupported reports whether the given platform is supported by ctrld. @@ -102,16 +98,6 @@ func Configure(c *ctrld.Config) error { if c.HasUpstreamSendClientInfo() { r := routerPlatform.Load() r.sendClientInfo = true - watcher, err := fsnotify.NewWatcher() - if err != nil { - return err - } - r.watcher = watcher - go r.watchClientInfoTable() - for file, readClienInfoFunc := range clientInfoFiles { - _ = readClienInfoFunc(file) - _ = r.watcher.Add(file) - } } configure := configureFunc[name] if err := configure(); err != nil {