From 4424602e3939e43595f6e6d2ec7436943ae38f6e Mon Sep 17 00:00:00 2001 From: Frank Denis Date: Mon, 3 Aug 2020 18:05:42 +0200 Subject: [PATCH] Start experimenting with better support for captive portals MacOS (and probably Windows and other systems) tries to fetch a URL before marking a network interface as available. During this time, applications cannot use the interface at all, not even bind their address. When DNS queries are sent to dnscrypt-proxy, this causes the system to wait for a response that can't come from the network, since we hit a dead lock here. The only option is to return hard-coded responses directly until te interface is available. The same captive portal configuration file can also serve a different purpose. Once the network is available, captive portal detection may not work as expected if the answer is cached for too long. In fact, it probably can't work at all since routers can't hijack DNS queries. Once thing we can do is redirect the list of names used for captive portal detection to the fallback resolvers. This may allow detection to work as expected while still using a secure channel for all other queries. --- dnscrypt-proxy/coldstart.go | 179 ++++++++++++++++++ dnscrypt-proxy/config.go | 4 +- .../example-captive-portal-handler.txt | 6 + dnscrypt-proxy/netprobe_others.go | 9 +- dnscrypt-proxy/netprobe_windows.go | 7 +- dnscrypt-proxy/proxy.go | 3 +- 6 files changed, 202 insertions(+), 6 deletions(-) create mode 100644 dnscrypt-proxy/coldstart.go create mode 100644 dnscrypt-proxy/example-captive-portal-handler.txt diff --git a/dnscrypt-proxy/coldstart.go b/dnscrypt-proxy/coldstart.go new file mode 100644 index 00000000..f045ebf4 --- /dev/null +++ b/dnscrypt-proxy/coldstart.go @@ -0,0 +1,179 @@ +package main + +import ( + "fmt" + "net" + "strings" + "time" + "unicode" + + "github.com/jedisct1/dlog" + "github.com/miekg/dns" +) + +type CaptivePortalEntryips []net.IP + +type CaptivePortalEntry struct { + name string + ips CaptivePortalEntryips +} + +type CaptivePortalHandler struct { + cancelChannels []chan struct{} +} + +func (captivePortalHandler *CaptivePortalHandler) Stop() { + for _, cancelChannel := range captivePortalHandler.cancelChannels { + cancelChannel <- struct{}{} + _ = <-cancelChannel + } +} + +func handleColdStartClient(clientPc *net.UDPConn, cancelChannel chan struct{}, ipsMap *map[string]CaptivePortalEntryips) bool { + buffer := make([]byte, MaxDNSPacketSize-1) + clientPc.SetDeadline(time.Now().Add(time.Duration(1) * time.Second)) + length, clientAddr, err := clientPc.ReadFrom(buffer) + exit := false + select { + case <-cancelChannel: + exit = true + default: + } + if exit { + return true + } + if neterr, ok := err.(net.Error); ok && neterr.Timeout() { + return false + } + if err != nil { + dlog.Warn(err) + return true + } + packet := buffer[:length] + msg := &dns.Msg{} + if err := msg.Unpack(packet); err != nil { + return false + } + if len(msg.Question) != 1 { + return false + } + question := msg.Question[0] + qType, ok := dns.TypeToString[question.Qtype] + if !ok { + qType = string(qType) + } + name, err := NormalizeQName(question.Name) + if err != nil { + return false + } + ips, ok := (*ipsMap)[name] + if !ok { + dlog.Infof("Coldstart query: [%v] (%v)", name, qType) + return false + } + dlog.Noticef("Coldstart query for captive portal detection: [%v] (%v)", name, qType) + if question.Qclass != dns.ClassINET { + return false + } + var respMsg *dns.Msg + respMsg = EmptyResponseFromMessage(msg) + ttl := uint32(1) + if question.Qtype == dns.TypeA { + for _, xip := range ips { + if ip := xip.To4(); ip != nil { + rr := new(dns.A) + rr.Hdr = dns.RR_Header{Name: question.Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: ttl} + rr.A = ip + respMsg.Answer = []dns.RR{rr} + break + } + } + } else if question.Qtype == dns.TypeAAAA { + for _, xip := range ips { + if ip := xip.To16(); ip != nil { + rr := new(dns.AAAA) + rr.Hdr = dns.RR_Header{Name: question.Name, Rrtype: dns.TypeAAAA, Class: dns.ClassINET, Ttl: ttl} + rr.AAAA = ip + respMsg.Answer = []dns.RR{rr} + break + } + } + } + if response, err := respMsg.Pack(); err == nil { + clientPc.WriteTo(response, clientAddr) + dlog.Noticef("Coldstart query synthesized: [%v] (%v)", name, qType) + } + return false +} + +func addColdStartListener(proxy *Proxy, ipsMap *map[string]CaptivePortalEntryips, listenAddrStr string, cancelChannel chan struct{}) error { + listenUDPAddr, err := net.ResolveUDPAddr("udp", listenAddrStr) + if err != nil { + return err + } + clientPc, err := net.ListenUDP("udp", listenUDPAddr) + if err != nil { + return err + } + go func() { + for !handleColdStartClient(clientPc, cancelChannel, ipsMap) { + } + clientPc.Close() + cancelChannel <- struct{}{} + }() + return nil +} + +func ColdStart(proxy *Proxy) (*CaptivePortalHandler, error) { + if len(proxy.captivePortalFile) == 0 { + return nil, nil + } + bin, err := ReadTextFile(proxy.captivePortalFile) + if err != nil { + dlog.Warn(err) + return nil, err + } + ipsMap := make(map[string]CaptivePortalEntryips) + for lineNo, line := range strings.Split(string(bin), "\n") { + line = TrimAndStripInlineComments(line) + if len(line) == 0 { + continue + } + name, ipsStr, ok := StringTwoFields(line) + if !ok { + return nil, fmt.Errorf( + "Syntax error for a captive portal rule at line %d", + 1+lineNo, + ) + } + name, err = NormalizeQName(name) + if err != nil { + continue + } + var ips []net.IP + for _, ip := range strings.Split(ipsStr, ",") { + ipStr := strings.TrimFunc(ip, unicode.IsSpace) + if ip := net.ParseIP(ipStr); ip != nil { + ips = append(ips, ip) + } else { + return nil, fmt.Errorf( + "Syntax error for a captive portal rule at line %d", + 1+lineNo, + ) + } + } + ipsMap[name] = ips + } + listenAddrStrs := proxy.listenAddresses + cancelChannels := make([]chan struct{}, 0) + for _, listenAddrStr := range listenAddrStrs { + cancelChannel := make(chan struct{}) + if err := addColdStartListener(proxy, &ipsMap, listenAddrStr, cancelChannel); err == nil { + cancelChannels = append(cancelChannels, cancelChannel) + } + } + captivePortalHandler := CaptivePortalHandler{ + cancelChannels: cancelChannels, + } + return &captivePortalHandler, nil +} diff --git a/dnscrypt-proxy/config.go b/dnscrypt-proxy/config.go index 4fc174e5..5bc450ab 100644 --- a/dnscrypt-proxy/config.go +++ b/dnscrypt-proxy/config.go @@ -68,6 +68,7 @@ type Config struct { BlockIPLegacy BlockIPConfigLegacy `toml:"ip_blacklist"` ForwardFile string `toml:"forwarding_rules"` CloakFile string `toml:"cloaking_rules"` + CaptivePortalFile string `toml:"captive_portal_handler"` StaticsConfig map[string]StaticConfig `toml:"static"` SourcesConfig map[string]SourceConfig `toml:"sources"` BrokenImplementations BrokenImplementationsConfig `toml:"broken_implementations"` @@ -547,6 +548,7 @@ func ConfigLoad(proxy *Proxy, flags *ConfigFlags) error { proxy.forwardFile = config.ForwardFile proxy.cloakFile = config.CloakFile + proxy.captivePortalFile = config.CaptivePortalFile allWeeklyRanges, err := ParseAllWeeklyRanges(config.AllWeeklyRanges) if err != nil { @@ -613,7 +615,7 @@ func ConfigLoad(proxy *Proxy, flags *ConfigFlags) error { } proxy.showCerts = *flags.ShowCerts || len(os.Getenv("SHOW_CERTS")) > 0 if !*flags.Check && !*flags.ShowCerts && !*flags.List && !*flags.ListAll { - if err := NetProbe(netprobeAddress, netprobeTimeout); err != nil { + if err := NetProbe(proxy, netprobeAddress, netprobeTimeout); err != nil { return err } for _, listenAddrStr := range proxy.listenAddresses { diff --git a/dnscrypt-proxy/example-captive-portal-handler.txt b/dnscrypt-proxy/example-captive-portal-handler.txt new file mode 100644 index 00000000..1ae4fb91 --- /dev/null +++ b/dnscrypt-proxy/example-captive-portal-handler.txt @@ -0,0 +1,6 @@ +captive.apple.com 17.253.109.201,17.253.113.202 +connectivitycheck.gstatic.com 64.233.165.94 +connectivitycheck.android.com 172.217.20.110 +www.msftncsi.com 2.19.98.8,2.19.98.59 +dns.msftncsi.com 131.107.255.255 +www.msftconnecttest.com 13.107.4.52 diff --git a/dnscrypt-proxy/netprobe_others.go b/dnscrypt-proxy/netprobe_others.go index 83726115..c596e16a 100644 --- a/dnscrypt-proxy/netprobe_others.go +++ b/dnscrypt-proxy/netprobe_others.go @@ -9,12 +9,15 @@ import ( "github.com/jedisct1/dlog" ) -func NetProbe(address string, timeout int) error { - cancelChannels := ColdStart([]string{"0.0.0.0:53"}) - defer ColdStartStop(cancelChannels) +func NetProbe(proxy *Proxy, address string, timeout int) error { if len(address) <= 0 || timeout == 0 { return nil } + if captivePortalHandler, err := ColdStart(proxy); err == nil { + defer captivePortalHandler.Stop() + } else { + dlog.Critical(err) + } remoteUDPAddr, err := net.ResolveUDPAddr("udp", address) if err != nil { return err diff --git a/dnscrypt-proxy/netprobe_windows.go b/dnscrypt-proxy/netprobe_windows.go index 79274f38..4888921b 100644 --- a/dnscrypt-proxy/netprobe_windows.go +++ b/dnscrypt-proxy/netprobe_windows.go @@ -7,10 +7,15 @@ import ( "github.com/jedisct1/dlog" ) -func NetProbe(address string, timeout int) error { +func NetProbe(proxy *Proxy, address string, timeout int) error { if len(address) <= 0 || timeout == 0 { return nil } + if captivePortalHandler, err := ColdStart(proxy); err == nil { + defer captivePortalHandler.Stop() + } else { + dlog.Critical(err) + } remoteUDPAddr, err := net.ResolveUDPAddr("udp", address) if err != nil { return err diff --git a/dnscrypt-proxy/proxy.go b/dnscrypt-proxy/proxy.go index 9655d9ef..2e9992bc 100644 --- a/dnscrypt-proxy/proxy.go +++ b/dnscrypt-proxy/proxy.go @@ -67,6 +67,7 @@ type Proxy struct { blockIPFormat string forwardFile string cloakFile string + captivePortalFile string pluginsGlobals PluginsGlobals sources []*Source clientsCount uint32 @@ -83,7 +84,7 @@ type Proxy struct { showCerts bool dohCreds *map[string]DOHClientCreds skipAnonIncompatbibleResolvers bool - anonDirectCertFallback bool + anonDirectCertFallback bool dns64Prefixes []string dns64Resolvers []string }