diff --git a/cmd/ctrld/cli.go b/cmd/ctrld/cli.go index e7f64ca..00d964d 100644 --- a/cmd/ctrld/cli.go +++ b/cmd/ctrld/cli.go @@ -5,6 +5,7 @@ import ( "context" "crypto/x509" "encoding/base64" + "encoding/json" "errors" "fmt" "io" @@ -26,6 +27,7 @@ import ( "github.com/go-playground/validator/v10" "github.com/kardianos/service" "github.com/miekg/dns" + "github.com/olekukonko/tablewriter" "github.com/pelletier/go-toml/v2" "github.com/rs/zerolog" "github.com/spf13/cobra" @@ -35,6 +37,7 @@ import ( "tailscale.com/net/interfaces" "github.com/Control-D-Inc/ctrld" + "github.com/Control-D-Inc/ctrld/internal/clientinfo" "github.com/Control-D-Inc/ctrld/internal/controld" ctrldnet "github.com/Control-D-Inc/ctrld/internal/net" "github.com/Control-D-Inc/ctrld/internal/router" @@ -751,6 +754,66 @@ NOTE: Uninstalling will set DNS to values provided by DHCP.`, uninstallCmdAlias.Flags().StringVarP(&ifaceStartStop, "iface", "", "auto", `Reset DNS setting for iface, "auto" means the default interface gateway`) uninstallCmdAlias.Flags().AddFlagSet(stopCmd.Flags()) rootCmd.AddCommand(uninstallCmdAlias) + + listClientsCmd := &cobra.Command{ + Use: "list", + Short: "List clients that ctrld discovered", + Args: cobra.NoArgs, + PreRun: func(cmd *cobra.Command, args []string) { + initConsoleLogging() + checkHasElevatedPrivilege() + }, + Run: func(cmd *cobra.Command, args []string) { + dir, err := userHomeDir() + if err != nil { + mainLog.Fatal().Err(err).Msg("failed to find ctrld home dir") + } + cc := newControlClient(filepath.Join(dir, ctrldControlUnixSock)) + resp, err := cc.post(listClientsPath, nil) + if err != nil { + mainLog.Fatal().Err(err).Msg("failed to get clients list") + } + defer resp.Body.Close() + + var clients []*clientinfo.Client + if err := json.NewDecoder(resp.Body).Decode(&clients); err != nil { + mainLog.Fatal().Err(err).Msg("failed to decode clients list result") + } + map2Slice := func(m map[string]struct{}) []string { + s := make([]string, 0, len(m)) + for k := range m { + s = append(s, k) + } + sort.Strings(s) + return s + } + data := make([][]string, len(clients)) + for i, c := range clients { + row := []string{ + c.IP.String(), + c.Hostname, + c.Mac, + strings.Join(map2Slice(c.Source), ","), + } + data[i] = row + } + table := tablewriter.NewWriter(os.Stdout) + table.SetHeader([]string{"IP", "Hostname", "Mac", "Discovered"}) + table.SetAutoFormatHeaders(false) + table.AppendBulk(data) + table.Render() + }, + } + clientsCmd := &cobra.Command{ + Use: "clients", + Short: "Manage clients", + Args: cobra.OnlyValidArgs, + ValidArgs: []string{ + listClientsCmd.Use, + }, + } + clientsCmd.AddCommand(listClientsCmd) + rootCmd.AddCommand(clientsCmd) } func writeConfigFile() error { diff --git a/cmd/ctrld/control_server.go b/cmd/ctrld/control_server.go index 437e4a8..a1681a7 100644 --- a/cmd/ctrld/control_server.go +++ b/cmd/ctrld/control_server.go @@ -2,13 +2,18 @@ package main import ( "context" + "encoding/json" "net" "net/http" "os" + "sort" "time" ) -const contentTypeJson = "application/json" +const ( + contentTypeJson = "application/json" + listClientsPath = "/clients" +) type controlServer struct { server *http.Server @@ -48,7 +53,16 @@ func (s *controlServer) register(pattern string, handler http.Handler) { } func (p *prog) registerControlServerHandler() { - // TODO: register handler here. + p.cs.mux.Handle(listClientsPath, http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + clients := p.ciTable.ListClients() + sort.Slice(clients, func(i, j int) bool { + return clients[i].IP.Less(clients[j].IP) + }) + if err := json.NewEncoder(w).Encode(&clients); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + })) } func jsonResponse(next http.Handler) http.Handler { diff --git a/go.mod b/go.mod index 6024239..1229987 100644 --- a/go.mod +++ b/go.mod @@ -15,10 +15,12 @@ require ( github.com/josharian/native v1.1.1-0.20230202152459-5c7d0dd6ab86 github.com/kardianos/service v1.2.1 github.com/miekg/dns v1.1.55 + github.com/olekukonko/tablewriter v0.0.5 github.com/pelletier/go-toml/v2 v2.0.8 github.com/quic-go/quic-go v0.32.0 github.com/rs/zerolog v1.28.0 github.com/spf13/cobra v1.7.0 + github.com/spf13/pflag v1.0.5 github.com/spf13/viper v1.16.0 github.com/stretchr/testify v1.8.3 github.com/vishvananda/netlink v1.2.1-beta.2 @@ -48,6 +50,7 @@ require ( github.com/magiconair/properties v1.8.7 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.18 // indirect + github.com/mattn/go-runewidth v0.0.14 // indirect github.com/mdlayher/ethernet v0.0.0-20190606142754-0394541c37b7 // indirect github.com/mdlayher/netlink v1.7.2 // indirect github.com/mdlayher/raw v0.0.0-20191009151244-50f2db8cc065 // indirect @@ -60,11 +63,11 @@ require ( github.com/quic-go/qtls-go1-18 v0.2.0 // indirect github.com/quic-go/qtls-go1-19 v0.2.0 // indirect github.com/quic-go/qtls-go1-20 v0.1.0 // indirect + github.com/rivo/uniseg v0.4.4 // indirect github.com/rogpeppe/go-internal v1.10.0 // indirect github.com/spf13/afero v1.9.5 // indirect github.com/spf13/cast v1.5.1 // indirect github.com/spf13/jwalterweatherman v1.1.0 // indirect - github.com/spf13/pflag v1.0.5 // indirect github.com/subosito/gotenv v1.4.2 // indirect github.com/u-root/uio v0.0.0-20230305220412-3e8cd9d6bf63 // indirect github.com/vishvananda/netns v0.0.4 // indirect diff --git a/go.sum b/go.sum index be89ee2..bdd9bef 100644 --- a/go.sum +++ b/go.sum @@ -193,6 +193,9 @@ github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27k github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= +github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mdlayher/ethernet v0.0.0-20190606142754-0394541c37b7 h1:lez6TS6aAau+8wXUP3G9I3TGlmPFEq2CTxBaRqY6AGE= github.com/mdlayher/ethernet v0.0.0-20190606142754-0394541c37b7/go.mod h1:U6ZQobyTjI/tJyq2HG+i/dfSoFUt8/aZCM+GKtmFk/Y= github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/g= @@ -206,6 +209,8 @@ github.com/miekg/dns v1.1.55 h1:GoQ4hpsj0nFLYe+bWiCToyrBEJXkQfOOIvFGFy0lEgo= github.com/miekg/dns v1.1.55/go.mod h1:uInx36IzPl7FYnDcMeVWxj9byh7DutNykX4G9Sj60FY= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/onsi/ginkgo/v2 v2.2.0 h1:3ZNA3L1c5FYDFTTxbFeVGGD8jYvjYauHD30YgLxVsNI= github.com/onsi/ginkgo/v2 v2.2.0/go.mod h1:MEH45j8TBi6u9BMogfbp0stKC5cdGjumZj5Y7AG4VIk= github.com/onsi/gomega v1.20.1 h1:PA/3qinGoukvymdIDV8pii6tiZgC8kbmJO6Z5+b002Q= @@ -230,6 +235,9 @@ github.com/quic-go/qtls-go1-20 v0.1.0 h1:d1PK3ErFy9t7zxKsG3NXBJXZjp/kMLoIb3y/kV5 github.com/quic-go/qtls-go1-20 v0.1.0/go.mod h1:JKtK6mjbAVcUTN/9jZpvLbGxvdWIKS8uT7EiStoU1SM= github.com/quic-go/quic-go v0.32.0 h1:lY02md31s1JgPiiyfqJijpu/UX/Iun304FI3yUqX7tA= github.com/quic-go/quic-go v0.32.0/go.mod h1:/fCsKANhQIeD5l76c2JFU+07gVE3KaA0FP+0zMWwfwo= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= +github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= diff --git a/internal/clientinfo/arp.go b/internal/clientinfo/arp.go index ef70031..8429b56 100644 --- a/internal/clientinfo/arp.go +++ b/internal/clientinfo/arp.go @@ -27,3 +27,20 @@ func (a *arpDiscover) LookupMac(ip string) string { } return val.(string) } + +func (a *arpDiscover) String() string { + return "arp" +} + +func (a *arpDiscover) List() []string { + var ips []string + a.ip.Range(func(key, value any) bool { + ips = append(ips, value.(string)) + return true + }) + a.mac.Range(func(key, value any) bool { + ips = append(ips, key.(string)) + return true + }) + return ips +} diff --git a/internal/clientinfo/client_info.go b/internal/clientinfo/client_info.go index 4dd757b..55fe0b1 100644 --- a/internal/clientinfo/client_info.go +++ b/internal/clientinfo/client_info.go @@ -1,6 +1,8 @@ package clientinfo import ( + "fmt" + "net/netip" "strings" "time" @@ -9,25 +11,35 @@ import ( // IpResolver is the interface for retrieving IP from Mac. type IpResolver interface { + fmt.Stringer + // LookupIP returns ip of the device with given mac. LookupIP(mac string) string + // List returns list of ip known by the resolver. + List() []string } // MacResolver is the interface for retrieving Mac from IP. type MacResolver interface { + fmt.Stringer + // LookupMac returns mac of the device with given ip. LookupMac(ip string) string } // HostnameByIpResolver is the interface for retrieving hostname from IP. type HostnameByIpResolver interface { + // LookupHostnameByIP returns hostname of the given ip. LookupHostnameByIP(ip string) string } // HostnameByMacResolver is the interface for retrieving hostname from Mac. type HostnameByMacResolver interface { + // LookupHostnameByMac returns hostname of the device with given mac. LookupHostnameByMac(mac string) string } +// HostnameResolver is the interface for retrieving hostname from either IP or Mac. type HostnameResolver interface { + fmt.Stringer HostnameByIpResolver HostnameByMacResolver } @@ -36,6 +48,13 @@ type refresher interface { refresh() error } +type Client struct { + IP netip.Addr + Mac string + Hostname string + Source map[string]struct{} +} + type Table struct { ipResolvers []IpResolver macResolvers []MacResolver @@ -146,9 +165,6 @@ func (t *Table) LookupIP(mac string) string { } func (t *Table) LookupMac(ip string) string { - t.arp.mac.Range(func(key, value any) bool { - return true - }) for _, r := range t.macResolvers { if mac := r.LookupMac(ip); mac != "" { return mac @@ -169,6 +185,86 @@ func (t *Table) LookupHostname(ip, mac string) string { return "" } +type macEntry struct { + mac string + src string +} + +type hostnameEntry struct { + name string + src string +} + +func (t *Table) lookupMacAll(ip string) []*macEntry { + var res []*macEntry + for _, r := range t.macResolvers { + res = append(res, &macEntry{mac: r.LookupMac(ip), src: r.String()}) + } + return res +} + +func (t *Table) lookupHostnameAll(ip, mac string) []*hostnameEntry { + var res []*hostnameEntry + for _, r := range t.hostnameResolvers { + src := r.String() + if name := r.LookupHostnameByIP(ip); name != "" { + res = append(res, &hostnameEntry{name: name, src: src}) + continue + } + if name := r.LookupHostnameByMac(mac); name != "" { + res = append(res, &hostnameEntry{name: name, src: src}) + continue + } + } + return res +} + +// ListClients returns list of clients discovered by ctrld. +func (t *Table) ListClients() []*Client { + for _, r := range t.refreshers { + _ = r.refresh() + } + ipMap := make(map[string]*Client) + for _, ir := range t.ipResolvers { + for _, ip := range ir.List() { + c, ok := ipMap[ip] + if !ok { + c = &Client{ + IP: netip.MustParseAddr(ip), + Source: map[string]struct{}{ir.String(): {}}, + } + ipMap[ip] = c + } else { + c.Source[ir.String()] = struct{}{} + } + } + } + for ip := range ipMap { + c := ipMap[ip] + for _, e := range t.lookupMacAll(ip) { + if c.Mac == "" && e.mac != "" { + c.Mac = e.mac + } + if e.mac != "" { + c.Source[e.src] = struct{}{} + } + } + for _, e := range t.lookupHostnameAll(ip, c.Mac) { + if c.Hostname == "" && e.name != "" { + c.Hostname = e.name + } + if e.name != "" { + c.Source[e.src] = struct{}{} + } + } + } + clients := make([]*Client, 0, len(ipMap)) + for _, c := range ipMap { + clients = append(clients, c) + } + return clients +} + func (t *Table) discoverDHCP() bool { if t.cfg.Service.DiscoverDHCP == nil { return true diff --git a/internal/clientinfo/dhcp.go b/internal/clientinfo/dhcp.go index 9ddc2ed..bcad26a 100644 --- a/internal/clientinfo/dhcp.go +++ b/internal/clientinfo/dhcp.go @@ -100,6 +100,23 @@ func (d *dhcp) LookupHostnameByMac(mac string) string { return val.(string) } +func (d *dhcp) String() string { + return "dhcp" +} + +func (d *dhcp) List() []string { + var ips []string + d.ip.Range(func(key, value any) bool { + ips = append(ips, value.(string)) + return true + }) + d.mac.Range(func(key, value any) bool { + ips = append(ips, key.(string)) + return true + }) + return ips +} + // AddLeaseFile adds given lease file for reading/watching clients info. func (d *dhcp) addLeaseFile(name string, format ctrld.LeaseFileFormat) error { if d.watcher == nil { diff --git a/internal/clientinfo/mdns.go b/internal/clientinfo/mdns.go index e2a9588..59ef7eb 100644 --- a/internal/clientinfo/mdns.go +++ b/internal/clientinfo/mdns.go @@ -43,6 +43,10 @@ func (m *mdns) LookupHostnameByMac(mac string) string { return "" } +func (m *mdns) String() string { + return "mdns" +} + func (m *mdns) init(quitCh chan struct{}) error { ifaces, err := multicastInterfaces() if err != nil { diff --git a/internal/clientinfo/merlin.go b/internal/clientinfo/merlin.go index 7e793ed..71c570c 100644 --- a/internal/clientinfo/merlin.go +++ b/internal/clientinfo/merlin.go @@ -65,3 +65,7 @@ func (m *merlinDiscover) parseMerlinCustomClientList(data string) { m.hostname.Store(mac, hostname) } } + +func (m *merlinDiscover) String() string { + return "merlin" +} diff --git a/internal/clientinfo/ptr_lookup.go b/internal/clientinfo/ptr_lookup.go index 600b67c..0de3f1a 100644 --- a/internal/clientinfo/ptr_lookup.go +++ b/internal/clientinfo/ptr_lookup.go @@ -36,6 +36,10 @@ func (p *ptrDiscover) LookupHostnameByMac(mac string) string { return "" } +func (p *ptrDiscover) String() string { + return "ptr" +} + func (p *ptrDiscover) lookupHostname(ip string) string { ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel()