arblant
firefox-vpn-client / core / internal / tunservice / programs.go
programs.go215 lines · 6.3 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 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215
package tunservice

import (
	"net/netip"
	"strings"
	"sync"
	"time"

	"github.com/armanlife/firefox-vpn-client/core/internal/logx"
	"github.com/armanlife/firefox-vpn-client/core/internal/tunapi"
)

// Programs that skip the VPN (tunapi.Program, the core's tun.programs): the
// service finds the program behind an app's new connection by its address
// on the adapter (findProgram: the system's table of sockets), and sends a
// chosen program's traffic directly, outside the TUN (egress), instead of to
// the core: its TCP and UDP, or its UDP alone. Mozilla carries no UDP, so
// every other program's UDP is refused; a refused call or game is told to
// the core once per program (tellRefused), so the app can offer to send it
// directly.

// programSet is the programs that skip the VPN: by lower-case file name,
// each to whether only its UDP skips it, and the patterns (with * or ?) in
// their order. A name wins over a pattern.
type programSet struct {
	names    map[string]bool
	patterns []tunapi.Program
}

func newProgramSet(programs []tunapi.Program) programSet {
	set := programSet{names: map[string]bool{}}
	for _, p := range programs {
		switch name := strings.TrimSpace(p.Name); {
		case name == "":
		case strings.ContainsAny(name, "*?"):
			set.patterns = append(set.patterns, tunapi.Program{Name: name, UDPOnly: p.UDPOnly})
		default:
			set.names[strings.ToLower(name)] = p.UDPOnly
		}
	}
	return set
}

func (s programSet) size() int { return len(s.names) + len(s.patterns) }

// lookup reports whether the program named name skips the VPN, and whether
// only its UDP does.
func (s programSet) lookup(name string) (udpOnly, ok bool) {
	if name == "" {
		return false, false
	}
	if udpOnly, ok := s.names[strings.ToLower(name)]; ok {
		return udpOnly, true
	}
	for _, p := range s.patterns {
		if tunapi.MatchProgram(p.Name, name) {
			return p.UDPOnly, true
		}
	}
	return false, false
}

// programName is the file name of an executable's path.
func programName(path string) string {
	if i := strings.LastIndexAny(path, `/\`); i >= 0 {
		return path[i+1:]
	}
	return path
}

// findProgram is the executable of the program whose socket has source as
// its local address ("" with an error when none is found); tests replace
// it.
var findProgram = ownerPath

// owners keeps the programs of UDP ports a moment: a port whose UDP is
// refused is asked about again with each packet.
type owners struct {
	mu   sync.Mutex
	seen map[netip.AddrPort]owner
}

type owner struct {
	path string
	at   time.Time
}

const (
	// ownerTTL is how long a UDP port's program is kept.
	ownerTTL = 5 * time.Second
	// maxOwners bounds owners.
	maxOwners = 4096
)

// udp is the program of the UDP port source.
func (o *owners) udp(source netip.AddrPort) string {
	now := time.Now()
	o.mu.Lock()
	if e, ok := o.seen[source]; ok && now.Sub(e.at) < ownerTTL {
		o.mu.Unlock()
		return e.path
	}
	o.mu.Unlock()
	path := lookProgram(true, source)
	o.mu.Lock()
	if o.seen == nil || len(o.seen) >= maxOwners {
		o.seen = map[netip.AddrPort]owner{}
	}
	o.seen[source] = owner{path: path, at: now}
	o.mu.Unlock()
	return path
}

// lookProgram is findProgram, "" when it finds none.
func lookProgram(udp bool, source netip.AddrPort) string {
	path, err := findProgram(udp, source)
	if err != nil {
		logx.Debug("TUN: no program found for source=%s udp=%v err=%s", source, udp, logx.Err(err))
		return ""
	}
	return path
}

// programSet is the session's programs that skip the VPN.
func (s *session) programSet() programSet {
	if p := s.programs.Load(); p != nil {
		return *p
	}
	return programSet{}
}

// setPrograms replaces the programs that skip the VPN: for the TCP
// connections that open from now on, and every UDP flow, which is judged
// again (a program just added may call at once; one just removed stops).
func (s *session) setPrograms(programs []tunapi.Program) {
	set := newProgramSet(programs)
	s.programs.Store(&set)
	if s.dev != nil {
		s.dev.ResetFlows()
	}
	logx.Info("TUN: programs that skip the VPN count=%d", set.size())
}

// directTCP reports whether the TCP connection from source is sent
// directly: its program's whole traffic skips the VPN.
func (s *session) directTCP(source netip.AddrPort) bool {
	set := s.programSet()
	if set.size() == 0 {
		return false
	}
	name := programName(lookProgram(false, source))
	if udpOnly, ok := set.lookup(name); ok && !udpOnly {
		logx.Debug("TUN: program=%s connects directly", name)
		return true
	}
	return false
}

// takeUDP decides on a port's first UDP packet to dst: it is taken (sent
// directly) when its program skips the VPN, except QUIC (port 443) of a
// program whose UDP alone skips it, which is refused so that it falls back
// to TCP through the VPN; everything else is refused, and a call or a game
// told to the core.
func (s *session) takeUDP(source, dst netip.AddrPort) bool {
	set := s.programSet()
	call := callLike(dst)
	if set.size() == 0 && !call {
		return false
	}
	path := s.owners.udp(source)
	name := programName(path)
	if udpOnly, ok := set.lookup(name); ok {
		return !udpOnly || dst.Port() != 443
	}
	if call && name != "" && !tunapi.SystemProgram(path) {
		s.tellRefused(name, path)
	}
	return false
}

// callLike reports whether UDP to dst looks like a call or a game: to an
// address on the internet (a fake one stands for a name), and not QUIC
// (443), DNS (53, 853), or the clock (123).
func callLike(dst netip.AddrPort) bool {
	switch dst.Port() {
	case 53, 123, 443, 853:
		return false
	}
	addr := dst.Addr().Unmap()
	return addr.IsGlobalUnicast() && !addr.IsPrivate()
}

// tellRefused tells the core, once per program in the session, that a
// program's UDP that looks like a call or a game was refused (protocol 2),
// with the program's executable. Until the TUN is routed the core waits for
// its replies, so nothing is sent then; the next refused packet tells it.
func (s *session) tellRefused(name, path string) {
	if s.protocol < 2 || !s.live.Load() {
		return
	}
	key := strings.ToLower(name)
	s.toldMu.Lock()
	if s.told[key] {
		s.toldMu.Unlock()
		return
	}
	if s.told == nil {
		s.told = map[string]bool{}
	}
	s.told[key] = true
	s.toldMu.Unlock()
	logx.Info("TUN: UDP of program=%s refused (Mozilla carries no UDP)", name)
	// Off the packet path: the core may be slow to read.
	go func() { _ = s.owner.Send(tunapi.Message{Type: tunapi.TypeUDPRefused, Program: name, Path: path}) }()
}