WIP: rebase fork onto upstream/main (v1.103.0) #15
@@ -811,6 +811,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
|
||||
tailscale.com/net/proxymux from tailscale.com/tsnet
|
||||
tailscale.com/net/routecheck from tailscale.com/client/local+
|
||||
tailscale.com/net/routecheck/peernode from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/net/routemanager from tailscale.com/ipn/ipnlocal
|
||||
💣 tailscale.com/net/sockopts from tailscale.com/wgengine/magicsock
|
||||
tailscale.com/net/socks5 from tailscale.com/tsnet
|
||||
tailscale.com/net/sockstats from tailscale.com/control/controlclient+
|
||||
|
||||
@@ -109,6 +109,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
tailscale.com/net/ping from tailscale.com/net/netcheck+
|
||||
tailscale.com/net/portmapper/portmappertype from tailscale.com/net/netcheck+
|
||||
tailscale.com/net/routecheck/peernode from tailscale.com/ipn/ipnlocal
|
||||
tailscale.com/net/routemanager from tailscale.com/ipn/ipnlocal
|
||||
tailscale.com/net/sockopts from tailscale.com/wgengine/magicsock
|
||||
tailscale.com/net/sockstats from tailscale.com/control/controlclient+
|
||||
tailscale.com/net/stun from tailscale.com/net/netcheck+
|
||||
|
||||
@@ -126,6 +126,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
tailscale.com/net/ping from tailscale.com/net/netcheck+
|
||||
tailscale.com/net/portmapper/portmappertype from tailscale.com/net/netcheck+
|
||||
tailscale.com/net/routecheck/peernode from tailscale.com/ipn/ipnlocal
|
||||
tailscale.com/net/routemanager from tailscale.com/ipn/ipnlocal
|
||||
tailscale.com/net/sockopts from tailscale.com/wgengine/magicsock
|
||||
tailscale.com/net/sockstats from tailscale.com/control/controlclient+
|
||||
tailscale.com/net/stun from tailscale.com/net/netcheck+
|
||||
|
||||
@@ -384,6 +384,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
||||
tailscale.com/net/proxymux from tailscale.com/cmd/tailscaled
|
||||
tailscale.com/net/routecheck from tailscale.com/feature/routecheck+
|
||||
tailscale.com/net/routecheck/peernode from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/net/routemanager from tailscale.com/ipn/ipnlocal
|
||||
tailscale.com/net/routetable from tailscale.com/doctor/routetable
|
||||
💣 tailscale.com/net/sockopts from tailscale.com/wgengine/magicsock+
|
||||
tailscale.com/net/socks5 from tailscale.com/cmd/tailscaled
|
||||
|
||||
@@ -210,6 +210,7 @@ tailscale.com/cmd/tsidp dependencies: (generated by github.com/tailscale/depawar
|
||||
tailscale.com/net/proxymux from tailscale.com/tsnet
|
||||
tailscale.com/net/routecheck from tailscale.com/client/local+
|
||||
tailscale.com/net/routecheck/peernode from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/net/routemanager from tailscale.com/ipn/ipnlocal
|
||||
💣 tailscale.com/net/sockopts from tailscale.com/wgengine/magicsock
|
||||
tailscale.com/net/socks5 from tailscale.com/tsnet
|
||||
tailscale.com/net/sockstats from tailscale.com/control/controlclient+
|
||||
|
||||
@@ -6126,6 +6126,12 @@ func (b *LocalBackend) authReconfigLocked() {
|
||||
}
|
||||
|
||||
oneCGNATRoute := shouldUseOneCGNATRoute(b.logf, b.sys.NetMon.Get(), b.sys.ControlKnobs(), version.OS())
|
||||
cn.updateRouteManagerPrefs(routePrefs{
|
||||
ExitNodeID: prefs.ExitNodeID(),
|
||||
ExitNodeSelected: prefs.ExitNodeID() != "" || prefs.ExitNodeIP().IsValid(),
|
||||
RouteAll: flags&netmap.AllowSubnetRoutes != 0,
|
||||
OneCGNAT: oneCGNATRoute,
|
||||
})
|
||||
rcfg := b.routerConfigLocked(cfg, prefs, nm, oneCGNATRoute)
|
||||
|
||||
// Add these extra Allowed IPs after router configuration, because the expected
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"tailscale.com/ipn"
|
||||
"tailscale.com/net/dns"
|
||||
"tailscale.com/net/routecheck/peernode"
|
||||
"tailscale.com/net/routemanager"
|
||||
"tailscale.com/net/tsaddr"
|
||||
"tailscale.com/syncs"
|
||||
"tailscale.com/tailcfg"
|
||||
@@ -164,6 +165,13 @@ type nodeBackend struct {
|
||||
// [nodeBackend.AwaitNodeKeyForTest]. It is populated lazily and remains
|
||||
// nil in production, where no test installs a waiter.
|
||||
keyWaitersForTest map[key.NodePublic]chan struct{}
|
||||
|
||||
// routeMgr tracks this node's view of which IPs route to which
|
||||
// peers and publishes lock-free snapshots for the data plane and
|
||||
// the OS router. It is initialized once and immutable, but its
|
||||
// Begin/Commit mutations must be serialized, which we do by only
|
||||
// mutating it with mu held.
|
||||
routeMgr *routemanager.RouteManager
|
||||
}
|
||||
|
||||
func newNodeBackend(ctx context.Context, logf logger.Logf, bus *eventbus.Bus) *nodeBackend {
|
||||
@@ -174,6 +182,7 @@ func newNodeBackend(ctx context.Context, logf logger.Logf, bus *eventbus.Bus) *n
|
||||
ctxCancel: ctxCancel,
|
||||
eventClient: bus.Client("ipnlocal.nodeBackend"),
|
||||
readyCh: make(chan struct{}),
|
||||
routeMgr: routemanager.New(logf),
|
||||
}
|
||||
// Default filter blocks everything and logs nothing.
|
||||
noneFilter := filter.NewAllowNone(logger.Discard, &netipx.IPSet{})
|
||||
@@ -757,16 +766,81 @@ func (nb *nodeBackend) ExtraDNSByName(hostname string) (_ netip.Addr, ok bool) {
|
||||
|
||||
func (nb *nodeBackend) updatePeersLocked() {
|
||||
nm := nb.netMap
|
||||
oldIDs := slices.Collect(maps.Keys(nb.peers))
|
||||
|
||||
if nm == nil {
|
||||
nb.peers = nil
|
||||
return
|
||||
} else {
|
||||
mapx.RepopulateNonzero(&nb.peers, func() {
|
||||
for _, p := range nm.Peers {
|
||||
nb.peers[p.ID()] = p
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
mapx.RepopulateNonzero(&nb.peers, func() {
|
||||
for _, p := range nm.Peers {
|
||||
nb.peers[p.ID()] = p
|
||||
// Resync the route manager to the new peer set. Upserts of
|
||||
// unchanged peers are cheap no-ops; this is a full-netmap event,
|
||||
// so O(n) work is expected here.
|
||||
rt := nb.routeMgr.Begin()
|
||||
for _, id := range oldIDs {
|
||||
if _, ok := nb.peers[id]; !ok {
|
||||
rt.RemovePeer(id)
|
||||
}
|
||||
}
|
||||
for _, p := range nb.peers {
|
||||
rt.UpsertPeer(p)
|
||||
}
|
||||
rt.Commit()
|
||||
}
|
||||
|
||||
// updateRouteManagerPrefs pushes the routing-relevant prefs and the
|
||||
// OneCGNAT decision into the route manager. The exit node arrives as
|
||||
// a stable ID from prefs and is resolved to the current numeric node
|
||||
// ID here; it resolves to zero (no exit node) if that peer is not in
|
||||
// the netmap yet, in which case a later netmap update re-runs this.
|
||||
// routePrefs is the subset of routing-relevant prefs (and derived
|
||||
// state) that [nodeBackend.updateRouteManagerPrefs] pushes into the
|
||||
// RouteManager.
|
||||
type routePrefs struct {
|
||||
// ExitNodeID is the stable node ID of the selected exit node, or
|
||||
// zero if none is selected.
|
||||
ExitNodeID tailcfg.StableNodeID
|
||||
|
||||
// ExitNodeSelected is whether the prefs select any exit node at
|
||||
// all, even one that doesn't resolve (e.g. an ExitNodeIP that
|
||||
// never resolved to an ExitNodeID); it mirrors
|
||||
// routerConfigLocked's blackhole condition.
|
||||
ExitNodeSelected bool
|
||||
|
||||
// RouteAll is whether advertised subnet routes from peers are
|
||||
// accepted.
|
||||
RouteAll bool
|
||||
|
||||
// OneCGNAT is whether the OS route set should collapse peers'
|
||||
// CGNAT addresses into the single /10 route.
|
||||
OneCGNAT bool
|
||||
}
|
||||
|
||||
func (nb *nodeBackend) updateRouteManagerPrefs(p routePrefs) {
|
||||
nb.mu.Lock()
|
||||
defer nb.mu.Unlock()
|
||||
var exitID tailcfg.NodeID
|
||||
if !p.ExitNodeID.IsZero() {
|
||||
// The lookup can miss: the selected exit node may not exist,
|
||||
// or may be a placeholder like MDM's "auto:any" awaiting
|
||||
// resolution. ExitNodeSelected then makes the RouteManager
|
||||
// blackhole internet traffic rather than treat it as "no
|
||||
// exit node"; see [routemanager.Prefs.ExitNodeSelected].
|
||||
exitID = nb.nodeByStableID[p.ExitNodeID]
|
||||
}
|
||||
rt := nb.routeMgr.Begin()
|
||||
rt.SetPrefs(routemanager.Prefs{
|
||||
ExitNodeID: exitID,
|
||||
ExitNodeSelected: p.ExitNodeSelected,
|
||||
RouteAll: p.RouteAll,
|
||||
})
|
||||
rt.SetTailnetConfig(routemanager.TailnetConfig{OneCGNAT: p.OneCGNAT})
|
||||
rt.Commit()
|
||||
}
|
||||
|
||||
// setPacketFilter stores the live packet filter rules and parsed
|
||||
@@ -810,6 +884,14 @@ func (nb *nodeBackend) UpdateNetmapDelta(muts []netmap.NodeMutation) (handled bo
|
||||
// call (e.g. its endpoints + online status both change)
|
||||
var mutableNodes map[tailcfg.NodeID]*tailcfg.Node
|
||||
|
||||
// Mirror peer add/remove mutations into the route manager.
|
||||
// The Commit is unconditional (even if we panic or return false)
|
||||
// to stay in sync with the peers map, which is likewise mutated
|
||||
// in place as the loop runs. And if we return false, we'll just
|
||||
// get a full netmap soon and reset all our state anyway.
|
||||
rt := nb.routeMgr.Begin()
|
||||
defer rt.Commit()
|
||||
|
||||
for _, m := range muts {
|
||||
switch m := m.(type) {
|
||||
case netmap.NodeMutationUpsert:
|
||||
@@ -824,6 +906,7 @@ func (nb *nodeBackend) UpdateNetmapDelta(muts []netmap.NodeMutation) (handled bo
|
||||
mak.Set(&nb.nodeByWGString, m.Node.Key().WireGuardGoString(), nid)
|
||||
mak.Set(&nb.nodeByStableID, m.Node.StableID(), nid)
|
||||
nb.addNodeNameLocked(m.Node.Name(), nid)
|
||||
rt.UpsertPeer(m.Node)
|
||||
continue
|
||||
case netmap.NodeMutationRemove:
|
||||
nid := m.NodeIDBeingMutated()
|
||||
@@ -838,6 +921,7 @@ func (nb *nodeBackend) UpdateNetmapDelta(muts []netmap.NodeMutation) (handled bo
|
||||
delete(nb.nodeByStableID, old.StableID())
|
||||
nb.removeNodeNameLocked(old.Name())
|
||||
delete(nb.peers, nid)
|
||||
rt.RemovePeer(nid)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"maps"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
"tailscale.com/net/routecheck/peernode"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/tstest"
|
||||
"tailscale.com/types/key"
|
||||
"tailscale.com/types/netmap"
|
||||
"tailscale.com/util/eventbus"
|
||||
"tailscale.com/util/mak"
|
||||
@@ -271,3 +273,83 @@ var _ RouteCheckReport = *new(routecheckReport)
|
||||
func (rp routecheckReport) IsReachable(_ tailcfg.NodeID) peernode.Reachability {
|
||||
return peernode.Reachability(rp)
|
||||
}
|
||||
|
||||
func TestNodeBackendRouteManager(t *testing.T) {
|
||||
nb := newNodeBackend(t.Context(), tstest.WhileTestRunningLogger(t), eventbus.New())
|
||||
|
||||
mkPeer := func(id tailcfg.NodeID, stableID tailcfg.StableNodeID, addr4 string, extra ...string) tailcfg.NodeView {
|
||||
n := &tailcfg.Node{
|
||||
ID: id,
|
||||
StableID: stableID,
|
||||
Key: key.NewNode().Public(),
|
||||
HomeDERP: 1, // required by the route manager's reachability filter
|
||||
Addresses: []netip.Prefix{
|
||||
netip.MustParsePrefix(addr4),
|
||||
},
|
||||
}
|
||||
n.AllowedIPs = append(n.AllowedIPs, n.Addresses...)
|
||||
for _, s := range extra {
|
||||
n.AllowedIPs = append(n.AllowedIPs, netip.MustParsePrefix(s))
|
||||
}
|
||||
return n.View()
|
||||
}
|
||||
wantPeerFor := func(ip string, want tailcfg.NodeView) {
|
||||
t.Helper()
|
||||
got, ok := nb.routeMgr.Outbound().Lookup(netip.MustParseAddr(ip))
|
||||
if !want.Valid() {
|
||||
if ok {
|
||||
t.Errorf("Outbound lookup %s = %v; want no match", ip, got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if !ok || got.Key != want.Key() {
|
||||
t.Errorf("Outbound lookup %s = %v, %v; want %v", ip, got, ok, want.Key())
|
||||
}
|
||||
}
|
||||
|
||||
p1 := mkPeer(1, "stable1", "100.64.0.1/32")
|
||||
p2 := mkPeer(2, "stable2", "100.64.0.2/32", "0.0.0.0/0", "::/0")
|
||||
|
||||
// A full netmap populates the route manager.
|
||||
nb.SetNetMap(&netmap.NetworkMap{Peers: []tailcfg.NodeView{p1, p2}})
|
||||
wantPeerFor("100.64.0.1", p1)
|
||||
wantPeerFor("100.64.0.2", p2)
|
||||
wantPeerFor("8.8.8.8", tailcfg.NodeView{}) // exit node not selected
|
||||
|
||||
// Selecting peer 2 as the exit node resolves its stable ID and
|
||||
// installs its /0 routes.
|
||||
nb.updateRouteManagerPrefs(routePrefs{ExitNodeID: "stable2", ExitNodeSelected: true})
|
||||
wantPeerFor("8.8.8.8", p2)
|
||||
|
||||
// A selected exit node that resolves to no current peer must
|
||||
// blackhole internet traffic, not fall back to "no exit node":
|
||||
// the default routes stay in the OS route set with no outbound
|
||||
// peer to carry them.
|
||||
nb.updateRouteManagerPrefs(routePrefs{ExitNodeID: "no-such-node", ExitNodeSelected: true})
|
||||
wantPeerFor("8.8.8.8", tailcfg.NodeView{})
|
||||
if !nb.routeMgr.OSRoutes().Get(netip.MustParsePrefix("0.0.0.0/0")) {
|
||||
t.Error("unresolved exit node: OSRoutes missing 0.0.0.0/0 blackhole route")
|
||||
}
|
||||
|
||||
nb.updateRouteManagerPrefs(routePrefs{})
|
||||
wantPeerFor("8.8.8.8", tailcfg.NodeView{})
|
||||
if nb.routeMgr.OSRoutes().Get(netip.MustParsePrefix("0.0.0.0/0")) {
|
||||
t.Error("no exit node: OSRoutes unexpectedly contains 0.0.0.0/0")
|
||||
}
|
||||
|
||||
// Incremental deltas: add peer 3, remove peer 1.
|
||||
p3 := mkPeer(3, "stable3", "100.64.0.3/32")
|
||||
if !nb.UpdateNetmapDelta([]netmap.NodeMutation{
|
||||
netmap.NodeMutationUpsert{Node: p3},
|
||||
netmap.MakeNodeMutationRemove(1),
|
||||
}) {
|
||||
t.Fatal("UpdateNetmapDelta not handled")
|
||||
}
|
||||
wantPeerFor("100.64.0.3", p3)
|
||||
wantPeerFor("100.64.0.1", tailcfg.NodeView{})
|
||||
|
||||
// A full netmap that drops a peer removes it from the route manager.
|
||||
nb.SetNetMap(&netmap.NetworkMap{Peers: []tailcfg.NodeView{p2}})
|
||||
wantPeerFor("100.64.0.3", tailcfg.NodeView{})
|
||||
wantPeerFor("100.64.0.2", p2)
|
||||
}
|
||||
|
||||
@@ -106,10 +106,22 @@ func (p peerView) hasDataPlaneAttrs() bool {
|
||||
// Prefs is the subset of ipn.Prefs that affects routing.
|
||||
type Prefs struct {
|
||||
// ExitNodeID is the node ID of the peer selected as this
|
||||
// node's exit node, or zero if no exit node is selected.
|
||||
// node's exit node, or zero if no exit node is selected or the
|
||||
// selected exit node does not resolve to a current peer.
|
||||
// (Callers resolve ipn.Prefs's stable node ID to a NodeID.)
|
||||
ExitNodeID tailcfg.NodeID
|
||||
|
||||
// ExitNodeSelected is whether the prefs select any exit node at
|
||||
// all, even one that doesn't resolve to a current peer (in which
|
||||
// case ExitNodeID is zero). When set, the exit routes are always
|
||||
// in the OS route set: with a resolved exit node they carry its
|
||||
// traffic, and without one they blackhole internet traffic
|
||||
// rather than let it escape to the local network, per the
|
||||
// [tailscale.com/ipn.Prefs.ExitNodeID] docs. MDM's "auto:any"
|
||||
// placeholder relies on the blackhole while an exit node is
|
||||
// still being chosen.
|
||||
ExitNodeSelected bool
|
||||
|
||||
// RouteAll is whether advertised subnet routes (non-exit
|
||||
// routes) from peers are accepted.
|
||||
RouteAll bool
|
||||
@@ -899,6 +911,13 @@ func (rm *RouteManager) applyDirty(dirty set.Set[netip.Prefix], res *Result) {
|
||||
var cgnatDirty []netip.Prefix
|
||||
for pfx := range dirty {
|
||||
want, wantOS := rm.desiredFor(pfx)
|
||||
if rm.prefs.ExitNodeSelected && tsaddr.IsExitRoute(pfx) {
|
||||
// The exit routes stay in the OS route set as long as an
|
||||
// exit node is selected, even with no eligible
|
||||
// contributor, to blackhole rather than leak internet
|
||||
// traffic. See [Prefs.ExitNodeSelected].
|
||||
wantOS = true
|
||||
}
|
||||
|
||||
if cur, ok := out.Get(pfx); (want != nil) != ok || (ok && cur != want) {
|
||||
if want != nil {
|
||||
@@ -984,6 +1003,13 @@ func (rm *RouteManager) rebuildAll(res *Result) {
|
||||
for _, pfx := range plain {
|
||||
osr.Insert(pfx)
|
||||
}
|
||||
if rm.prefs.ExitNodeSelected {
|
||||
// Blackhole (or carry) internet traffic while any exit node
|
||||
// is selected, resolved or not. See [Prefs.ExitNodeSelected].
|
||||
for _, pfx := range tsaddr.ExitRoutes() {
|
||||
osr.Insert(pfx)
|
||||
}
|
||||
}
|
||||
if len(rm.ulaPfxs) > 0 {
|
||||
osr.Insert(tsaddr.TailscaleULARange())
|
||||
}
|
||||
|
||||
@@ -895,3 +895,48 @@ func TestExtraAllowedIPsPeerLifecycle(t *testing.T) {
|
||||
wantOutbound(t, rm, "fe80::1234", k1, true)
|
||||
wantOSRoutes(t, rm, "100.64.0.1/32", "fd7a:115c:a1e0::/48")
|
||||
}
|
||||
|
||||
// Tests that the exit routes stay in the OS route set whenever an
|
||||
// exit node is selected, even one that resolves to no current peer
|
||||
// (Prefs.ExitNodeSelected with a zero ExitNodeID), so internet
|
||||
// traffic is blackholed rather than escaping to the local network.
|
||||
// This mirrors the long-standing behavior documented on
|
||||
// ipn.Prefs.ExitNodeID and relied on by MDM's "auto:any" placeholder.
|
||||
func TestExitNodeBlackhole(t *testing.T) {
|
||||
rm := New(t.Logf)
|
||||
exitPeer := peer1()
|
||||
exitPeer.Routes = tsaddr.ExitRoutes()
|
||||
commit(rm, func(m *Mutation) { m.upsertPeer(exitPeer) })
|
||||
|
||||
// No exit node selected: no default routes, no outbound winner.
|
||||
wantOSRoutes(t, rm, "100.64.0.1/32", "fd7a:115c:a1e0::/48")
|
||||
wantOutbound(t, rm, "8.8.8.8", key.NodePublic{}, false)
|
||||
|
||||
// A resolved exit node carries the default routes.
|
||||
commit(rm, func(m *Mutation) {
|
||||
m.SetPrefs(Prefs{ExitNodeID: 1, ExitNodeSelected: true})
|
||||
})
|
||||
wantOSRoutes(t, rm, "100.64.0.1/32", "fd7a:115c:a1e0::/48", "0.0.0.0/0", "::/0")
|
||||
wantOutbound(t, rm, "8.8.8.8", k1, true)
|
||||
|
||||
// An unresolved exit node (nonexistent, or not yet resolved)
|
||||
// keeps the default routes with no outbound winner: a blackhole.
|
||||
commit(rm, func(m *Mutation) {
|
||||
m.SetPrefs(Prefs{ExitNodeID: 0, ExitNodeSelected: true})
|
||||
})
|
||||
wantOSRoutes(t, rm, "100.64.0.1/32", "fd7a:115c:a1e0::/48", "0.0.0.0/0", "::/0")
|
||||
wantOutbound(t, rm, "8.8.8.8", key.NodePublic{}, false)
|
||||
|
||||
// Removing the exit-capable peer while still selected keeps the
|
||||
// blackhole in place via the incremental path.
|
||||
commit(rm, func(m *Mutation) {
|
||||
m.SetPrefs(Prefs{ExitNodeID: 1, ExitNodeSelected: true})
|
||||
})
|
||||
commit(rm, func(m *Mutation) { m.RemovePeer(1) })
|
||||
wantOSRoutes(t, rm, "0.0.0.0/0", "::/0")
|
||||
wantOutbound(t, rm, "8.8.8.8", key.NodePublic{}, false)
|
||||
|
||||
// Deselecting the exit node removes the default routes.
|
||||
commit(rm, func(m *Mutation) { m.SetPrefs(Prefs{}) })
|
||||
wantOSRoutes(t, rm)
|
||||
}
|
||||
|
||||
@@ -206,6 +206,7 @@ tailscale.com/tsnet dependencies: (generated by github.com/tailscale/depaware)
|
||||
tailscale.com/net/proxymux from tailscale.com/tsnet
|
||||
tailscale.com/net/routecheck from tailscale.com/client/local+
|
||||
tailscale.com/net/routecheck/peernode from tailscale.com/ipn/ipnlocal+
|
||||
tailscale.com/net/routemanager from tailscale.com/ipn/ipnlocal
|
||||
💣 tailscale.com/net/sockopts from tailscale.com/wgengine/magicsock
|
||||
tailscale.com/net/socks5 from tailscale.com/tsnet
|
||||
tailscale.com/net/sockstats from tailscale.com/control/controlclient+
|
||||
|
||||
Reference in New Issue
Block a user