arblant
firefox-vpn-client / core / internal / tunservice / macdns.go
macdns.go193 lines · 5.1 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
package tunservice

import (
	"encoding/json"
	"errors"
	"fmt"
	"net/netip"
	"os"
	"strings"
)

// The DNS servers of macOS's network services while the TUN runs
// (filters_darwin.go). The routes alone would let DNS to a server on the
// local network (the router's, usually) leave outside the TUN, and macOS
// has no filters like Windows' to stop it: every network service's DNS
// servers are set to the TUN's while it runs, as wg-quick does, and set
// back afterwards. What each service had is written to a backup file first,
// so that a service that crashed sets it back when it starts again. It is
// plain code without a build tag, so its tests run everywhere.

// macDNS sets the DNS servers with networksetup; tests replace run.
type macDNS struct {
	run func(args ...string) (string, error)
	// backup is the backup file.
	backup string
	// server is the TUN's DNS server.
	server string
}

// macDNSBackup is what each service had: its DNS servers, or none for
// networksetup's "Empty" (the network's own).
type macDNSBackup map[string][]string

// services lists every network service, the disabled ones too (one may be
// turned on while the TUN runs).
func (m macDNS) services() ([]string, error) {
	out, err := m.run("-listallnetworkservices")
	if err != nil {
		return nil, err
	}
	var names []string
	for i, line := range strings.Split(out, "\n") {
		line = strings.TrimSpace(line)
		// The first line explains the asterisk, which marks a disabled
		// service.
		if line == "" || (i == 0 && strings.Contains(line, "asterisk")) {
			continue
		}
		names = append(names, strings.TrimPrefix(line, "*"))
	}
	if len(names) == 0 {
		return nil, errors.New("no network service")
	}
	return names, nil
}

// get reads a service's DNS servers: none when it has the network's own.
func (m macDNS) get(service string) ([]string, error) {
	out, err := m.run("-getdnsservers", service)
	if err != nil {
		return nil, err
	}
	var servers []string
	for _, line := range strings.Split(out, "\n") {
		line = strings.TrimSpace(line)
		if line == "" {
			continue
		}
		// "There aren't any DNS Servers set on Wi-Fi."
		if strings.Contains(line, "aren't any DNS Servers") {
			return nil, nil
		}
		if _, err := netip.ParseAddr(line); err != nil {
			return nil, fmt.Errorf("reading the DNS servers of %s: %s", service, out)
		}
		servers = append(servers, line)
	}
	return servers, nil
}

// set gives a service the DNS servers, or the network's own for none.
func (m macDNS) set(service string, servers []string) error {
	args := append([]string{"-setdnsservers", service}, servers...)
	if len(servers) == 0 {
		args = append(args, "Empty")
	}
	out, err := m.run(args...)
	if err != nil {
		return err
	}
	// networksetup says what failed, but does not always exit with an
	// error.
	if strings.Contains(out, "Error") || strings.Contains(out, "not a recognized") {
		return fmt.Errorf("setting the DNS servers of %s: %s", service, out)
	}
	return nil
}

func (m macDNS) ours(servers []string) bool {
	return len(servers) == 1 && servers[0] == m.server
}

// apply gives every service the TUN's DNS server, noting first what it had.
// A setting changed while the TUN runs is noted again, and set back after.
func (m macDNS) apply() error {
	services, err := m.services()
	if err != nil {
		return err
	}
	backup, _, err := m.loadBackup()
	if err != nil {
		return err
	}
	var errs []error
	for _, s := range services {
		current, err := m.get(s)
		if err != nil {
			errs = append(errs, err)
			continue
		}
		if m.ours(current) {
			continue
		}
		backup[s] = current
		if err := m.saveBackup(backup); err != nil {
			return err
		}
		if err := m.set(s, []string{m.server}); err != nil {
			errs = append(errs, err)
		}
	}
	return errors.Join(errs...)
}

// restore sets back what apply changed: each service that still has the
// TUN's DNS server gets what it had. The backup is removed once all went
// well.
func (m macDNS) restore() error {
	backup, found, err := m.loadBackup()
	if err != nil || !found {
		return err
	}
	services, err := m.services()
	if err != nil {
		return err
	}
	var errs []error
	for _, s := range services {
		current, err := m.get(s)
		if err != nil {
			errs = append(errs, err)
			continue
		}
		// A setting the user chose meanwhile stays.
		if !m.ours(current) {
			continue
		}
		if err := m.set(s, backup[s]); err != nil {
			errs = append(errs, err)
		}
	}
	if len(errs) > 0 {
		return errors.Join(errs...)
	}
	if err := os.Remove(m.backup); err != nil && !errors.Is(err, os.ErrNotExist) {
		return err
	}
	return nil
}

// loadBackup reads the backup, and whether there is one.
func (m macDNS) loadBackup() (macDNSBackup, bool, error) {
	backup := macDNSBackup{}
	data, err := os.ReadFile(m.backup)
	if errors.Is(err, os.ErrNotExist) {
		return backup, false, nil
	}
	if err != nil {
		return nil, false, err
	}
	if err := json.Unmarshal(data, &backup); err != nil {
		return nil, true, fmt.Errorf("reading %s: %w", m.backup, err)
	}
	return backup, true, nil
}

func (m macDNS) saveBackup(backup macDNSBackup) error {
	data, err := json.Marshal(backup)
	if err != nil {
		return err
	}
	return replaceFile(m.backup, data, 0o644)
}