From 0e3f76429901d6353ac3938f0c8d09ca67c1b4a4 Mon Sep 17 00:00:00 2001 From: Cuong Manh Le Date: Wed, 24 Sep 2025 17:02:16 +0700 Subject: [PATCH] feat: add --rfc1918 flag for explicit LAN client support Make RFC1918 listener spawning opt-in via --rfc1918 flag instead of automatic behavior. This allows users to explicitly control when ctrld listens on private network addresses to receive DNS queries from LAN clients, improving security and configurability. Refactor network interface detection to better distinguish between physical and virtual interfaces, ensuring only real hardware interfaces are used for RFC1918 address binding. --- cmd/cli/commands.go | 2 ++ cmd/cli/dns_proxy.go | 6 ++-- cmd/cli/main.go | 1 + nameservers_linux.go | 25 +++++++++++++ net_darwin.go | 35 +++++++++++++++++++ .../net_darwin_test.go => net_darwin_test.go | 2 +- net_others.go | 15 ++++++++ resolver.go | 7 +++- 8 files changed, 88 insertions(+), 5 deletions(-) create mode 100644 net_darwin.go rename cmd/cli/net_darwin_test.go => net_darwin_test.go (99%) create mode 100644 net_others.go diff --git a/cmd/cli/commands.go b/cmd/cli/commands.go index 3733f71..a1074f2 100644 --- a/cmd/cli/commands.go +++ b/cmd/cli/commands.go @@ -189,6 +189,7 @@ func initRunCmd() *cobra.Command { runCmd.Flags().StringVarP(&iface, "iface", "", "", `Update DNS setting for iface, "auto" means the default interface gateway`) _ = runCmd.Flags().MarkHidden("iface") runCmd.Flags().StringVarP(&cdUpstreamProto, "proto", "", ctrld.ResolverTypeDOH, `Control D upstream type, either "doh" or "doh3"`) + runCmd.Flags().BoolVarP(&rfc1918, "rfc1918", "", false, "Listen on RFC1918 addresses when 127.0.0.1 is the only listener") runCmd.FParseErrWhitelist = cobra.FParseErrWhitelist{UnknownFlags: true} rootCmd.AddCommand(runCmd) @@ -531,6 +532,7 @@ NOTE: running "ctrld start" without any arguments will start already installed c startCmd.Flags().BoolVarP(&skipSelfChecks, "skip_self_checks", "", false, `Skip self checks after installing ctrld service`) startCmd.Flags().BoolVarP(&startOnly, "start_only", "", false, "Do not install new service") _ = startCmd.Flags().MarkHidden("start_only") + startCmd.Flags().BoolVarP(&rfc1918, "rfc1918", "", false, "Listen on RFC1918 addresses when 127.0.0.1 is the only listener") routerCmd := &cobra.Command{ Use: "setup", diff --git a/cmd/cli/dns_proxy.go b/cmd/cli/dns_proxy.go index f1aa445..994741b 100644 --- a/cmd/cli/dns_proxy.go +++ b/cmd/cli/dns_proxy.go @@ -207,8 +207,8 @@ func (p *prog) serveDNS(listenerNum string) error { return nil }) } - // When we spawn a listener on 127.0.0.1, also spawn listeners on the RFC1918 - // addresses of the machine. So ctrld could receive queries from LAN clients. + // When we spawn a listener on 127.0.0.1, also spawn listeners on the RFC1918 addresses of the machine + // if explicitly set via setting rfc1918 flag, so ctrld could receive queries from LAN clients. if needRFC1918Listeners(listenerConfig) { g.Go(func() error { for _, addr := range ctrld.Rfc1918Addresses() { @@ -1039,7 +1039,7 @@ func (p *prog) queryFromSelf(ip string) bool { // needRFC1918Listeners reports whether ctrld need to spawn listener for RFC 1918 addresses. // This is helpful for non-desktop platforms to receive queries from LAN clients. func needRFC1918Listeners(lc *ctrld.ListenerConfig) bool { - return lc.IP == "127.0.0.1" && lc.Port == 53 && !ctrld.IsDesktopPlatform() + return rfc1918 && lc.IP == "127.0.0.1" && lc.Port == 53 } // ipFromARPA parses a FQDN arpa domain and return the IP address if valid. diff --git a/cmd/cli/main.go b/cmd/cli/main.go index 6a8cb62..0783975 100644 --- a/cmd/cli/main.go +++ b/cmd/cli/main.go @@ -39,6 +39,7 @@ var ( skipSelfChecks bool cleanup bool startOnly bool + rfc1918 bool mainLog atomic.Pointer[zerolog.Logger] consoleWriter zerolog.ConsoleWriter diff --git a/nameservers_linux.go b/nameservers_linux.go index 13a5507..37a9ed2 100644 --- a/nameservers_linux.go +++ b/nameservers_linux.go @@ -5,9 +5,12 @@ import ( "bytes" "encoding/hex" "net" + "net/netip" "os" "strings" + "tailscale.com/net/netmon" + "github.com/Control-D-Inc/ctrld/internal/dns/resolvconffile" ) @@ -128,3 +131,25 @@ func virtualInterfaces() set { } return s } + +// validInterfacesMap returns a set containing non virtual interfaces. +// TODO: deduplicated with cmd/cli/net_linux.go in v2. +func validInterfaces() set { + m := make(map[string]struct{}) + vis := virtualInterfaces() + netmon.ForeachInterface(func(i netmon.Interface, prefixes []netip.Prefix) { + if _, existed := vis[i.Name]; existed { + return + } + m[i.Name] = struct{}{} + }) + // Fallback to default route interface if found nothing. + if len(m) == 0 { + defaultRoute, err := netmon.DefaultRoute() + if err != nil { + return m + } + m[defaultRoute.InterfaceName] = struct{}{} + } + return m +} diff --git a/net_darwin.go b/net_darwin.go new file mode 100644 index 0000000..5b01e9f --- /dev/null +++ b/net_darwin.go @@ -0,0 +1,35 @@ +package ctrld + +import ( + "bufio" + "bytes" + "io" + "os/exec" + "strings" +) + +// validInterfaces returns a set of all valid hardware ports. +// TODO: deduplicated with cmd/cli/net_darwin.go in v2. +func validInterfaces() map[string]struct{} { + b, err := exec.Command("networksetup", "-listallhardwareports").Output() + if err != nil { + return nil + } + return parseListAllHardwarePorts(bytes.NewReader(b)) +} + +// parseListAllHardwarePorts parses output of "networksetup -listallhardwareports" +// and returns map presents all hardware ports. +func parseListAllHardwarePorts(r io.Reader) map[string]struct{} { + m := make(map[string]struct{}) + scanner := bufio.NewScanner(r) + for scanner.Scan() { + line := scanner.Text() + after, ok := strings.CutPrefix(line, "Device: ") + if !ok { + continue + } + m[after] = struct{}{} + } + return m +} diff --git a/cmd/cli/net_darwin_test.go b/net_darwin_test.go similarity index 99% rename from cmd/cli/net_darwin_test.go rename to net_darwin_test.go index 9ef1906..8f9734f 100644 --- a/cmd/cli/net_darwin_test.go +++ b/net_darwin_test.go @@ -1,4 +1,4 @@ -package cli +package ctrld import ( "maps" diff --git a/net_others.go b/net_others.go new file mode 100644 index 0000000..ae7ab8e --- /dev/null +++ b/net_others.go @@ -0,0 +1,15 @@ +//go:build !darwin && !windows && !linux + +package ctrld + +import "tailscale.com/net/netmon" + +// validInterfaces returns a set containing only default route interfaces. +// TODO: deuplicated with cmd/cli/net_others.go in v2. +func validInterfaces() map[string]struct{} { + defaultRoute, err := netmon.DefaultRoute() + if err != nil { + return nil + } + return map[string]struct{}{defaultRoute.InterfaceName: {}} +} diff --git a/resolver.go b/resolver.go index 27c0108..3aeddd0 100644 --- a/resolver.go +++ b/resolver.go @@ -729,10 +729,15 @@ func newResolverWithNameserver(nameservers []string) *osResolver { return r } -// Rfc1918Addresses returns the list of local interfaces private IP addresses +// Rfc1918Addresses returns the list of local physical interfaces private IP addresses func Rfc1918Addresses() []string { + vis := validInterfaces() var res []string netmon.ForeachInterface(func(i netmon.Interface, prefixes []netip.Prefix) { + // Skip virtual interfaces. + if _, existed := vis[i.Name]; !existed { + return + } addrs, _ := i.Addrs() for _, addr := range addrs { ipNet, ok := addr.(*net.IPNet)