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.
This commit is contained in:
Cuong Manh Le
2023-06-21 23:40:10 +07:00
committed by Cuong Manh Le
parent 472bb05e95
commit 9fe6af684f
8 changed files with 250 additions and 223 deletions

View File

@@ -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
}

View File

@@ -1,107 +0,0 @@
package router
import (
"io"
"strings"
"testing"
"github.com/Control-D-Inc/ctrld"
)
func Test_normalizeIP(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{"v4", "127.0.0.1", "127.0.0.1"},
{"v4 with index", "127.0.0.1%lo", "127.0.0.1"},
{"v6", "fe80::1", "fe80::1"},
{"v6 with index", "fe80::1%22002", "fe80::1"},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := normalizeIP(tc.in); got != tc.want {
t.Errorf("normalizeIP() = %v, want %v", got, tc.want)
}
})
}
}
func Test_readClientInfoReader(t *testing.T) {
tests := []struct {
name string
in string
readFunc func(r io.Reader) error
mac string
}{
{
"good dnsmasq",
`1683329857 e6:20:59:b8:c1:6d 192.168.1.186 * 01:e6:20:59:b8:c1:6d
`,
dnsmasqReadClientInfoReader,
"e6:20:59:b8:c1:6d",
},
{
"bad dnsmasq seen on UDMdream machine",
`1683329857 e6:20:59:b8:c1:6e 192.168.1.111 * 01:e6:20:59:b8:c1:6e
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,
"e6:20:59:b8:c1:6e",
},
{
"isc-dhcpd good",
`lease 192.168.1.1 {
hardware ethernet 00:00:00:00:00:01;
client-hostname "host-1";
}
`,
iscDHCPReadClientInfoReader,
"00:00:00:00:00:01",
},
{
"isc-dhcpd bad mac",
`lease 192.168.1.1 {
hardware ethernet invalid-mac;
client-hostname "host-1";
}
lease 192.168.1.2 {
hardware ethernet 00:00:00:00:00:02;
client-hostname "host-2";
}
`,
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,
"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)
if err := tc.readFunc(strings.NewReader(tc.in)); err != nil {
t.Errorf("readClientInfoReader() error = %v", err)
}
info, existed := r.mac.Load(tc.mac)
if !existed {
t.Error("client info missing")
}
if ci, ok := info.(*ctrld.ClientInfo); ok && existed && ci.Mac != tc.mac {
t.Errorf("mac mismatched, got: %q, want: %q", ci.Mac, tc.mac)
} else {
t.Log(ci)
}
})
}
}

View File

@@ -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 {