arblant
firefox-vpn-client / core / internal / service / dnstest.go
dnstest.go200 lines · 5.7 KB
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200
package service

import (
	"context"
	"net/http"
	"net/netip"
	"net/url"
	"strings"
	"sync"
	"time"

	"github.com/armanlife/firefox-vpn-client/core/internal/config"
	"github.com/armanlife/firefox-vpn-client/core/internal/proxy"
	"github.com/armanlife/firefox-vpn-client/core/internal/resolver"
)

const dnsTestTimeout = 8 * time.Second

// dnsTestHost is one host name resolved by the DNS test, through the
// configured servers and through the system resolver for comparison.
type dnsTestHost struct {
	Host        string   `json:"host"`
	Role        string   `json:"role"`
	Addrs       []string `json:"addrs,omitempty"`
	Source      string   `json:"source,omitempty"`
	MS          int64    `json:"ms"`
	Error       string   `json:"error,omitempty"`
	SystemAddrs []string `json:"system_addrs,omitempty"`
	SystemMS    int64    `json:"system_ms"`
	SystemError string   `json:"system_error,omitempty"`
	// Differs is set when both answered with no address in common. Alone
	// it means little: sites served from many places (CDNs) give each
	// resolver a nearby address.
	Differs bool `json:"differs"`
	// Filtered is set when the system answered with a block-page address
	// (a private, loopback, or link-local address, or Iran's filtering
	// page): the network tampers with DNS.
	Filtered bool `json:"filtered"`
}

// systemDNS resolves with the system resolver; tests replace it.
var systemDNS = func(ctx context.Context, host string) (resolver.Result, error) {
	return (*resolver.Resolver)(nil).Lookup(ctx, host)
}

// dnsDescription summarizes the DNS settings for the status view.
func dnsDescription(cfg config.DNS) string {
	r, err := resolver.New(cfg.Servers, cfg.FallbackToSystem)
	if err != nil {
		return "invalid: " + err.Error()
	}
	return r.String()
}

// dnsTestTargets lists the names this program resolves itself.
func (d *Daemon) dnsTestTargets() [][2]string {
	cfg := d.config()
	targets := [][2]string{
		{"api.accounts.firefox.com", "Firefox Accounts"},
		{hostOf(cfg.Network.GuardianEndpoint), "Guardian (proxy pass)"},
		{"firefox.settings.services.mozilla.com", "Server list"},
	}
	upstream := "us.m1.fastly-masque.net"
	if engine := d.currentEngine(); engine != nil {
		if selected, _ := engine.selection(); selected.Addr != "" {
			if host, _, err := splitHostPortDefault(selected.Addr); err == nil {
				upstream = host
			}
		}
	}
	return append(targets, [2]string{upstream, "Proxy server (Fastly)"})
}

func hostOf(raw string) string {
	u, err := url.Parse(raw)
	if err != nil || u.Hostname() == "" {
		return raw
	}
	return u.Hostname()
}

func splitHostPortDefault(addr string) (string, string, error) {
	u, err := proxy.NormalizeURL(addr)
	if err != nil {
		return "", "", err
	}
	return u.Hostname(), u.Port(), nil
}

// testDNS resolves every target through servers (without falling back)
// and through the system resolver.
func (d *Daemon) testDNS(ctx context.Context, servers []string) ([]dnsTestHost, error) {
	configured, err := resolver.New(servers, false)
	if err != nil {
		return nil, err
	}
	targets := d.dnsTestTargets()
	results := make([]dnsTestHost, len(targets))
	var wg sync.WaitGroup
	for i, target := range targets {
		wg.Add(1)
		go func(i int, host, role string) {
			defer wg.Done()
			ctx, cancel := context.WithTimeout(ctx, dnsTestTimeout)
			defer cancel()
			out := dnsTestHost{Host: host, Role: role}
			if !configured.System() {
				start := time.Now()
				res, err := configured.Lookup(ctx, host)
				out.MS = time.Since(start).Milliseconds()
				if err != nil {
					out.Error = err.Error()
				} else {
					out.Addrs, out.Source = addrStrings(res), res.Source
				}
			}
			start := time.Now()
			sys, err := systemDNS(ctx, host)
			out.SystemMS = time.Since(start).Milliseconds()
			if err != nil {
				out.SystemError = err.Error()
			} else {
				out.SystemAddrs = addrStrings(sys)
			}
			out.Differs = len(out.Addrs) > 0 && len(out.SystemAddrs) > 0 && !overlap(out.Addrs, out.SystemAddrs)
			out.Filtered = anyBlockPage(out.SystemAddrs)
			results[i] = out
		}(i, target[0], target[1])
	}
	wg.Wait()
	return results, nil
}

func addrStrings(res resolver.Result) []string {
	out := make([]string, len(res.Addrs))
	for i, a := range res.Addrs {
		out[i] = a.String()
	}
	return out
}

// iranBlockPageV6 holds the IPv6 addresses of Iran's filtering page
// (its IPv4 ones, 10.10.34.34-36, are private).
var iranBlockPageV6 = netip.MustParsePrefix("2001:4188:2:600:10:10:34:0/112")

// anyBlockPage reports whether a public name resolved to an address that
// cannot be its real one: a filtering network's answer.
func anyBlockPage(addrs []string) bool {
	for _, s := range addrs {
		a, err := netip.ParseAddr(s)
		if err != nil {
			continue
		}
		a = a.Unmap()
		if a.IsPrivate() || a.IsLoopback() || a.IsUnspecified() || a.IsLinkLocalUnicast() || iranBlockPageV6.Contains(a) {
			return true
		}
	}
	return false
}

func overlap(a, b []string) bool {
	seen := make(map[string]bool, len(a))
	for _, x := range a {
		seen[x] = true
	}
	for _, y := range b {
		if seen[y] {
			return true
		}
	}
	return false
}

// handleDNSTest tests the servers in the request body (the unsaved form),
// or the saved ones when the body names none.
func (s *dashboardServer) handleDNSTest(w http.ResponseWriter, r *http.Request) {
	var in struct {
		Servers *[]string `json:"servers"`
	}
	if !readJSON(w, r, &in) {
		return
	}
	servers := s.d.config().DNS.Servers
	if in.Servers != nil {
		servers = *in.Servers
	}
	cleaned := make([]string, 0, len(servers))
	for _, spec := range servers {
		if spec = strings.TrimSpace(spec); spec != "" {
			cleaned = append(cleaned, spec)
		}
	}
	results, err := s.d.testDNS(r.Context(), cleaned)
	if err != nil {
		writeError(w, http.StatusBadRequest, err.Error())
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{"servers": cleaned, "results": results})
}