From f7c124d99d0e38edaf1d8e55aaf3d0fd91a197f9 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_run.go | 1 + cmd/cli/commands_service_start.go | 1 + cmd/cli/dns_proxy.go | 4 ++- cmd/cli/main.go | 1 + nameservers_linux.go | 25 +++++++++++++ nameservers_windows.go | 4 +++ net_darwin.go | 35 +++++++++++++++++++ .../net_darwin_test.go => net_darwin_test.go | 2 +- net_others.go | 15 ++++++++ resolver.go | 7 +++- 10 files changed, 92 insertions(+), 3 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_run.go b/cmd/cli/commands_run.go index abb74bb..9d3260b 100644 --- a/cmd/cli/commands_run.go +++ b/cmd/cli/commands_run.go @@ -50,6 +50,7 @@ func InitRunCmd(rootCmd *cobra.Command) *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) diff --git a/cmd/cli/commands_service_start.go b/cmd/cli/commands_service_start.go index f8a9d98..0831371 100644 --- a/cmd/cli/commands_service_start.go +++ b/cmd/cli/commands_service_start.go @@ -348,6 +348,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") // Start command alias startCmdAlias := &cobra.Command{ diff --git a/cmd/cli/dns_proxy.go b/cmd/cli/dns_proxy.go index 60b316e..9bfa970 100644 --- a/cmd/cli/dns_proxy.go +++ b/cmd/cli/dns_proxy.go @@ -142,6 +142,8 @@ func (p *prog) startListeners(ctx context.Context, cfg *ctrld.ListenerConfig, ha }) } + // 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(cfg) { logger.Debug().Str("protocol", proto).Msg("Starting RFC1918 listeners") g.Go(func() error { @@ -1279,7 +1281,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 95d8356..7581a16 100644 --- a/cmd/cli/main.go +++ b/cmd/cli/main.go @@ -41,6 +41,7 @@ var ( skipSelfChecks bool cleanup bool startOnly bool + rfc1918 bool mainLog atomic.Pointer[ctrld.Logger] consoleWriter zapcore.Core diff --git a/nameservers_linux.go b/nameservers_linux.go index 8f877a6..8c93524 100644 --- a/nameservers_linux.go +++ b/nameservers_linux.go @@ -6,9 +6,12 @@ import ( "context" "encoding/hex" "net" + "net/netip" "os" "strings" + "tailscale.com/net/netmon" + "github.com/Control-D-Inc/ctrld/internal/dns/resolvconffile" ) @@ -129,3 +132,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/nameservers_windows.go b/nameservers_windows.go index 4ea0422..547aac2 100644 --- a/nameservers_windows.go +++ b/nameservers_windows.go @@ -444,3 +444,7 @@ func ValidInterfaces(ctx context.Context) map[string]struct{} { } return m } + +func validInterfaces() map[string]struct{} { + return ValidInterfaces(context.Background()) +} 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 55dabe6..425786d 100644 --- a/resolver.go +++ b/resolver.go @@ -709,10 +709,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)