cmd/tailscale/cli: suggest exit node
Updates tailscale/corp#17516 Signed-off-by: Claire Wang <claire@tailscale.com>
This commit is contained in:
parent
3aca29e00e
commit
343b29e971
|
@ -1460,6 +1460,19 @@ func (lc *LocalClient) TailFSShareList(ctx context.Context) (map[string]*tailfs.
|
|||
return shares, err
|
||||
}
|
||||
|
||||
// SuggestDERPExitNode returns the tailcfg.StableNodeID of a suggested exit node to connect to.
|
||||
func (lc *LocalClient) SuggestDERPExitNode(ctx context.Context) (tailcfg.StableNodeID, error) {
|
||||
body, err := lc.send(ctx, "POST", "/localapi/v0/suggest-derp-exit-node", 200, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error %w: %s", err, body)
|
||||
}
|
||||
nodeID, err := decodeJSON[tailcfg.StableNodeID](body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return nodeID, nil
|
||||
}
|
||||
|
||||
// IPNBusWatcher is an active subscription (watch) of the local tailscaled IPN bus.
|
||||
// It's returned by LocalClient.WatchIPNBus.
|
||||
//
|
||||
|
|
|
@ -37,6 +37,16 @@ var exitNodeCmd = &ffcli.Command{
|
|||
return fs
|
||||
})(),
|
||||
},
|
||||
{
|
||||
Name: "suggest",
|
||||
ShortUsage: "exit-node suggest",
|
||||
ShortHelp: "Picks the best available exit node",
|
||||
Exec: runExitNodeSuggest,
|
||||
FlagSet: (func() *flag.FlagSet {
|
||||
fs := newFlagSet("suggest")
|
||||
return fs
|
||||
})(),
|
||||
},
|
||||
},
|
||||
Exec: func(context.Context, []string) error {
|
||||
return errors.New("exit-node subcommand required; run 'tailscale exit-node -h' for details")
|
||||
|
@ -97,11 +107,26 @@ func runExitNodeList(ctx context.Context, args []string) error {
|
|||
}
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintln(w, "# To use an exit node, use `tailscale set --exit-node=` followed by the hostname or IP")
|
||||
fmt.Fprintln(w, "# To use an exit node, use `tailscale set --exit-node=` followed by the hostname or IP. To have Tailscale recommend an exit node, use `tailscale exit-node suggest`.")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// runExitNodeSuggest returns a suggested exit node ID to connect to and shows the chosen exit node tailcfg.StableNodeID.
|
||||
// If there are no derp based exit nodes to choose from or there is a failure in finding a suggestion, the command will return an error indicating so.
|
||||
func runExitNodeSuggest(ctx context.Context, args []string) error {
|
||||
suggestedNodeID, err := localClient.SuggestDERPExitNode(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to suggest exit node. Error: %v", err)
|
||||
}
|
||||
if suggestedNodeID == "" {
|
||||
fmt.Println("Unable to suggest an exit node")
|
||||
} else {
|
||||
fmt.Printf("Suggested exit node id: %v. To set as exit node run `tailscale set --exit-node=<nodeid>`.\n", suggestedNodeID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// peerStatus returns a string representing the current state of
|
||||
// a peer. If there is no notable state, a - is returned.
|
||||
func peerStatus(peer *ipnstate.PeerStatus) string {
|
||||
|
|
|
@ -286,7 +286,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
|
|||
tailscale.com/net/flowtrack from tailscale.com/net/packet+
|
||||
💣 tailscale.com/net/interfaces from tailscale.com/cmd/tailscaled+
|
||||
tailscale.com/net/netaddr from tailscale.com/ipn+
|
||||
tailscale.com/net/netcheck from tailscale.com/wgengine/magicsock
|
||||
tailscale.com/net/netcheck from tailscale.com/wgengine/magicsock+
|
||||
tailscale.com/net/neterror from tailscale.com/net/dns/resolver+
|
||||
tailscale.com/net/netkernelconf from tailscale.com/ipn/ipnlocal
|
||||
tailscale.com/net/netknob from tailscale.com/logpolicy+
|
||||
|
|
|
@ -73,6 +73,9 @@ type Knobs struct {
|
|||
// ProbeUDPLifetime is whether the node should probe UDP path lifetime on
|
||||
// the tail end of an active direct connection in magicsock.
|
||||
ProbeUDPLifetime atomic.Bool
|
||||
|
||||
// SuggestExitNode is whether the exit node suggestion feature can be used.
|
||||
SuggestExitNode atomic.Bool
|
||||
}
|
||||
|
||||
// UpdateFromNodeAttributes updates k (if non-nil) based on the provided self
|
||||
|
@ -100,6 +103,7 @@ func (k *Knobs) UpdateFromNodeAttributes(selfNodeAttrs []tailcfg.NodeCapability,
|
|||
forceNfTables = has(tailcfg.NodeAttrLinuxMustUseNfTables)
|
||||
seamlessKeyRenewal = has(tailcfg.NodeAttrSeamlessKeyRenewal)
|
||||
probeUDPLifetime = has(tailcfg.NodeAttrProbeUDPLifetime)
|
||||
suggestExitNode = has(tailcfg.NodeAttrSuggestExitNode)
|
||||
)
|
||||
|
||||
if has(tailcfg.NodeAttrOneCGNATEnable) {
|
||||
|
@ -122,6 +126,7 @@ func (k *Knobs) UpdateFromNodeAttributes(selfNodeAttrs []tailcfg.NodeCapability,
|
|||
k.LinuxForceNfTables.Store(forceNfTables)
|
||||
k.SeamlessKeyRenewal.Store(seamlessKeyRenewal)
|
||||
k.ProbeUDPLifetime.Store(probeUDPLifetime)
|
||||
k.SuggestExitNode.Store(suggestExitNode)
|
||||
}
|
||||
|
||||
// AsDebugJSON returns k as something that can be marshalled with json.Marshal
|
||||
|
@ -145,5 +150,6 @@ func (k *Knobs) AsDebugJSON() map[string]any {
|
|||
"LinuxForceNfTables": k.LinuxForceNfTables.Load(),
|
||||
"SeamlessKeyRenewal": k.SeamlessKeyRenewal.Load(),
|
||||
"ProbeUDPLifetime": k.ProbeUDPLifetime.Load(),
|
||||
"SuggestExitNode": k.SuggestExitNode.Load(),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,6 +13,8 @@ import (
|
|||
"io"
|
||||
"log"
|
||||
"maps"
|
||||
"math"
|
||||
"math/rand"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
|
@ -57,6 +59,7 @@ import (
|
|||
"tailscale.com/net/dnscache"
|
||||
"tailscale.com/net/dnsfallback"
|
||||
"tailscale.com/net/interfaces"
|
||||
"tailscale.com/net/netcheck"
|
||||
"tailscale.com/net/netkernelconf"
|
||||
"tailscale.com/net/netmon"
|
||||
"tailscale.com/net/netns"
|
||||
|
@ -5990,3 +5993,98 @@ func mayDeref[T any](p *T) (v T) {
|
|||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
func (b *LocalBackend) suggestExitNodeEnabled() bool {
|
||||
return b.ControlKnobs().SuggestExitNode.Load()
|
||||
}
|
||||
|
||||
var errNoExitNodes = errors.New("no exit nodes available")
|
||||
var errUnableToPick = errors.New("unable to pick candidate")
|
||||
|
||||
// SuggestDERPExitNode returns a tailcfg.StableNodeID of a suggested exit node given the local backend's netmap and last report.
|
||||
func (b *LocalBackend) SuggestDERPExitNode() (tailcfg.StableNodeID, error) {
|
||||
b.mu.Lock()
|
||||
lastReport := b.MagicConn().GetLastNetcheckReport(b.ctx)
|
||||
netMap := b.netMap
|
||||
rng := rand.New(rand.NewSource(rand.Int63()))
|
||||
b.mu.Unlock()
|
||||
if b.suggestExitNodeEnabled() {
|
||||
return suggestDERPExitNode(lastReport, netMap, rng)
|
||||
} else {
|
||||
return "", fmt.Errorf("Unable to choose exit node")
|
||||
}
|
||||
}
|
||||
|
||||
func suggestDERPExitNode(lastReport *netcheck.Report, netMap *netmap.NetworkMap, rng *rand.Rand) (tailcfg.StableNodeID, error) {
|
||||
peers := netMap.Peers
|
||||
var preferredExitNodeID tailcfg.StableNodeID
|
||||
peerRegionMap := make(map[int][]tailcfg.NodeView, len(netMap.DERPMap.Regions))
|
||||
sortedRegions := make([]int, 0, len(netMap.DERPMap.Regions))
|
||||
for r := range netMap.DERPMap.Regions {
|
||||
sortedRegions = append(sortedRegions, r)
|
||||
}
|
||||
|
||||
sortedRegions = sortRegions(sortedRegions, lastReport)
|
||||
|
||||
for _, peer := range peers {
|
||||
if online := peer.Online(); online != nil && !*online {
|
||||
continue
|
||||
}
|
||||
if tsaddr.ContainsExitRoutes(peer.AllowedIPs()) {
|
||||
if peer.DERP() == "" {
|
||||
continue
|
||||
}
|
||||
ipp, err := netip.ParseAddrPort(peer.DERP())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if ipp.Addr() == tailcfg.DerpMagicIPAddr {
|
||||
regionID := int(ipp.Port())
|
||||
peerRegionMap[regionID] = append(peerRegionMap[regionID], peer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, r := range sortedRegions {
|
||||
peers, ok := peerRegionMap[r]
|
||||
if ok && len(peers) > 0 {
|
||||
preferredExitNode, err := pick(peers, rng)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
preferredExitNodeID = preferredExitNode.StableID()
|
||||
break
|
||||
}
|
||||
}
|
||||
if preferredExitNodeID.IsZero() {
|
||||
return preferredExitNodeID, errNoExitNodes
|
||||
}
|
||||
return preferredExitNodeID, nil
|
||||
}
|
||||
|
||||
// pick randomly selects a tailcfg.NodeView given a list of tailcfg.NodeView and rand.Rand.
|
||||
func pick(candidates []tailcfg.NodeView, rng *rand.Rand) (tailcfg.NodeView, error) {
|
||||
if len(candidates) < 1 {
|
||||
return (&tailcfg.Node{}).View(), errUnableToPick
|
||||
}
|
||||
if len(candidates) == 1 {
|
||||
return candidates[0], nil
|
||||
}
|
||||
return candidates[rng.Intn(len(candidates))], nil
|
||||
}
|
||||
|
||||
// sortRegions returns a list of sorted regions by ascending latency given a list of region IDs and a netcheck report.
|
||||
func sortRegions(regions []int, lastReport *netcheck.Report) []int {
|
||||
sort.Slice(regions, func(i, j int) bool {
|
||||
iLatency, ok := lastReport.RegionLatency[regions[i]]
|
||||
if !ok || iLatency == 0 {
|
||||
iLatency = math.MaxInt
|
||||
}
|
||||
jLatency, ok := lastReport.RegionLatency[regions[j]]
|
||||
if !ok || jLatency == 0 {
|
||||
jLatency = math.MaxInt
|
||||
}
|
||||
return iLatency < jLatency
|
||||
})
|
||||
return regions
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ import (
|
|||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
|
@ -23,6 +24,7 @@ import (
|
|||
"tailscale.com/ipn"
|
||||
"tailscale.com/ipn/store/mem"
|
||||
"tailscale.com/net/interfaces"
|
||||
"tailscale.com/net/netcheck"
|
||||
"tailscale.com/net/tsaddr"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/tsd"
|
||||
|
@ -2173,3 +2175,324 @@ func TestOnTailnetDefaultAutoUpdate(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestDerpExitNode(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
lastReport netcheck.Report
|
||||
netMap netmap.NetworkMap
|
||||
wantValue tailcfg.StableNodeID
|
||||
wantError error
|
||||
}{
|
||||
{
|
||||
name: "2 derp based exit nodes in same region",
|
||||
lastReport: netcheck.Report{
|
||||
RegionLatency: map[int]time.Duration{
|
||||
1: 10 * time.Millisecond,
|
||||
2: 20 * time.Millisecond,
|
||||
3: 30 * time.Millisecond,
|
||||
},
|
||||
},
|
||||
netMap: netmap.NetworkMap{
|
||||
SelfNode: (&tailcfg.Node{
|
||||
Addresses: []netip.Prefix{
|
||||
netip.MustParsePrefix("100.64.1.1/32"),
|
||||
netip.MustParsePrefix("fe70::1/128"),
|
||||
},
|
||||
}).View(),
|
||||
DERPMap: &tailcfg.DERPMap{
|
||||
Regions: map[int]*tailcfg.DERPRegion{
|
||||
1: {},
|
||||
2: {},
|
||||
3: {},
|
||||
4: {},
|
||||
5: {},
|
||||
6: {},
|
||||
7: {},
|
||||
8: {},
|
||||
},
|
||||
},
|
||||
Peers: []tailcfg.NodeView{
|
||||
(&tailcfg.Node{
|
||||
ID: 2,
|
||||
StableID: "2",
|
||||
DERP: "127.3.3.40:1",
|
||||
AllowedIPs: []netip.Prefix{
|
||||
netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0"),
|
||||
},
|
||||
}).View(),
|
||||
(&tailcfg.Node{
|
||||
ID: 3,
|
||||
StableID: "3",
|
||||
DERP: "127.3.3.40:1",
|
||||
AllowedIPs: []netip.Prefix{
|
||||
netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0"),
|
||||
},
|
||||
}).View(),
|
||||
},
|
||||
},
|
||||
wantValue: tailcfg.StableNodeID("2"),
|
||||
},
|
||||
{
|
||||
name: "2 derp based exit nodes, different regions, no latency measurements",
|
||||
lastReport: netcheck.Report{
|
||||
RegionLatency: map[int]time.Duration{
|
||||
1: 0,
|
||||
2: 0,
|
||||
3: 0,
|
||||
},
|
||||
},
|
||||
netMap: netmap.NetworkMap{
|
||||
SelfNode: (&tailcfg.Node{
|
||||
Addresses: []netip.Prefix{
|
||||
netip.MustParsePrefix("100.64.1.1/32"),
|
||||
netip.MustParsePrefix("fe70::1/128"),
|
||||
},
|
||||
}).View(),
|
||||
DERPMap: &tailcfg.DERPMap{
|
||||
Regions: map[int]*tailcfg.DERPRegion{
|
||||
1: {},
|
||||
2: {},
|
||||
3: {},
|
||||
4: {},
|
||||
5: {},
|
||||
6: {},
|
||||
7: {},
|
||||
8: {},
|
||||
},
|
||||
},
|
||||
Peers: []tailcfg.NodeView{
|
||||
(&tailcfg.Node{
|
||||
ID: 2,
|
||||
StableID: "2",
|
||||
DERP: "127.3.3.40:1",
|
||||
AllowedIPs: []netip.Prefix{
|
||||
netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0"),
|
||||
},
|
||||
}).View(),
|
||||
(&tailcfg.Node{
|
||||
ID: 3,
|
||||
StableID: "3",
|
||||
DERP: "127.3.3.40:2",
|
||||
AllowedIPs: []netip.Prefix{
|
||||
netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0"),
|
||||
},
|
||||
}).View(),
|
||||
},
|
||||
},
|
||||
wantValue: tailcfg.StableNodeID("2"),
|
||||
},
|
||||
{
|
||||
name: "no derp based exit nodes",
|
||||
lastReport: netcheck.Report{
|
||||
RegionLatency: map[int]time.Duration{
|
||||
1: 0,
|
||||
2: 0,
|
||||
3: 0,
|
||||
},
|
||||
},
|
||||
netMap: netmap.NetworkMap{
|
||||
SelfNode: (&tailcfg.Node{
|
||||
Addresses: []netip.Prefix{
|
||||
netip.MustParsePrefix("100.64.1.1/32"),
|
||||
netip.MustParsePrefix("fe70::1/128"),
|
||||
},
|
||||
}).View(),
|
||||
DERPMap: &tailcfg.DERPMap{
|
||||
Regions: map[int]*tailcfg.DERPRegion{
|
||||
1: {},
|
||||
2: {},
|
||||
3: {},
|
||||
4: {},
|
||||
5: {},
|
||||
6: {},
|
||||
7: {},
|
||||
8: {},
|
||||
},
|
||||
},
|
||||
Peers: []tailcfg.NodeView{
|
||||
(&tailcfg.Node{
|
||||
ID: 2,
|
||||
StableID: "2",
|
||||
AllowedIPs: []netip.Prefix{
|
||||
netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0"),
|
||||
},
|
||||
}).View(),
|
||||
(&tailcfg.Node{
|
||||
ID: 3,
|
||||
StableID: "3",
|
||||
AllowedIPs: []netip.Prefix{
|
||||
netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0"),
|
||||
},
|
||||
}).View(),
|
||||
},
|
||||
},
|
||||
wantError: errNoExitNodes,
|
||||
},
|
||||
{
|
||||
name: "no exit nodes",
|
||||
lastReport: netcheck.Report{
|
||||
RegionLatency: map[int]time.Duration{
|
||||
1: 0,
|
||||
2: 0,
|
||||
3: 0,
|
||||
},
|
||||
},
|
||||
netMap: netmap.NetworkMap{
|
||||
SelfNode: (&tailcfg.Node{
|
||||
Addresses: []netip.Prefix{
|
||||
netip.MustParsePrefix("100.64.1.1/32"),
|
||||
netip.MustParsePrefix("fe70::1/128"),
|
||||
},
|
||||
}).View(),
|
||||
DERPMap: &tailcfg.DERPMap{
|
||||
Regions: map[int]*tailcfg.DERPRegion{
|
||||
1: {},
|
||||
2: {},
|
||||
3: {},
|
||||
4: {},
|
||||
5: {},
|
||||
6: {},
|
||||
7: {},
|
||||
8: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantError: errNoExitNodes,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := suggestDERPExitNode(&tt.lastReport, &tt.netMap, rand.New(rand.NewSource(10)))
|
||||
if got != tt.wantValue || err != tt.wantError {
|
||||
t.Errorf("got value %v error %v want %v error %v", got, err, tt.wantValue, tt.wantError)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestExitNodeSortRegions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
regions []int
|
||||
lastReport netcheck.Report
|
||||
wantValue []int
|
||||
}{
|
||||
{
|
||||
name: "list of regions and netcheck report has latency values",
|
||||
regions: []int{1, 3, 5},
|
||||
lastReport: netcheck.Report{
|
||||
RegionLatency: map[int]time.Duration{
|
||||
1: 3,
|
||||
3: 2,
|
||||
5: 1,
|
||||
},
|
||||
},
|
||||
wantValue: []int{5, 3, 1},
|
||||
},
|
||||
{
|
||||
name: "empty list of regions",
|
||||
regions: []int{},
|
||||
lastReport: netcheck.Report{
|
||||
RegionLatency: map[int]time.Duration{},
|
||||
},
|
||||
wantValue: []int{},
|
||||
},
|
||||
{
|
||||
name: "list of regions and netcheck report doesn't have all regions' values",
|
||||
regions: []int{1, 3, 5},
|
||||
lastReport: netcheck.Report{
|
||||
RegionLatency: map[int]time.Duration{
|
||||
1: 0,
|
||||
3: 1,
|
||||
5: 0,
|
||||
},
|
||||
},
|
||||
wantValue: []int{3, 1, 5},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := sortRegions(tt.regions, &tt.lastReport)
|
||||
if !reflect.DeepEqual(got, tt.wantValue) {
|
||||
t.Errorf("got value %v want %v", got, tt.wantValue)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestExitNodePick(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
candidates []tailcfg.NodeView
|
||||
rng *rand.Rand
|
||||
wantValue tailcfg.NodeView
|
||||
wantError error
|
||||
}{
|
||||
{
|
||||
name: ">1 candidates",
|
||||
candidates: []tailcfg.NodeView{
|
||||
(&tailcfg.Node{
|
||||
ID: 2,
|
||||
StableID: "2",
|
||||
AllowedIPs: []netip.Prefix{
|
||||
netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0"),
|
||||
},
|
||||
}).View(),
|
||||
(&tailcfg.Node{
|
||||
ID: 3,
|
||||
StableID: "3",
|
||||
AllowedIPs: []netip.Prefix{
|
||||
netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0"),
|
||||
},
|
||||
}).View(),
|
||||
},
|
||||
rng: rand.New(rand.NewSource(2)),
|
||||
wantValue: (&tailcfg.Node{
|
||||
ID: 2,
|
||||
StableID: "2",
|
||||
AllowedIPs: []netip.Prefix{
|
||||
netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0"),
|
||||
},
|
||||
}).View(),
|
||||
},
|
||||
{
|
||||
name: "<1 candidates",
|
||||
candidates: []tailcfg.NodeView{},
|
||||
rng: rand.New(rand.NewSource(2)),
|
||||
wantValue: (&tailcfg.Node{}).View(),
|
||||
wantError: errUnableToPick,
|
||||
},
|
||||
{
|
||||
name: "1 candidate",
|
||||
candidates: []tailcfg.NodeView{
|
||||
(&tailcfg.Node{
|
||||
ID: 2,
|
||||
StableID: "2",
|
||||
AllowedIPs: []netip.Prefix{
|
||||
netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0"),
|
||||
},
|
||||
}).View(),
|
||||
},
|
||||
rng: rand.New(rand.NewSource(2)),
|
||||
wantValue: (&tailcfg.Node{
|
||||
ID: 2,
|
||||
StableID: "2",
|
||||
AllowedIPs: []netip.Prefix{
|
||||
netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0"),
|
||||
},
|
||||
}).View(),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := pick(tt.candidates, tt.rng)
|
||||
if !reflect.DeepEqual(got, tt.wantValue) || err != tt.wantError {
|
||||
t.Errorf("got value %v error %v want %v error %v", got, err, tt.wantValue, tt.wantError)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -134,6 +134,7 @@ var handler = map[string]localAPIHandler{
|
|||
"update/check": (*Handler).serveUpdateCheck,
|
||||
"update/install": (*Handler).serveUpdateInstall,
|
||||
"update/progress": (*Handler).serveUpdateProgress,
|
||||
"suggest-derp-exit-node": (*Handler).serveSuggestDERPExitNode,
|
||||
}
|
||||
|
||||
var (
|
||||
|
@ -2626,3 +2627,21 @@ var (
|
|||
// User-visible LocalAPI endpoints.
|
||||
metricFilePutCalls = clientmetric.NewCounter("localapi_file_put")
|
||||
)
|
||||
|
||||
// serveSuggestDerpExitNode serves a POST endpoint for returning a suggested exit node.
|
||||
func (h *Handler) serveSuggestDERPExitNode(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.PermitWrite {
|
||||
http.Error(w, "access denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if r.Method != "POST" {
|
||||
http.Error(w, "want POST", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
suggestedExitNodeID, err := h.b.SuggestDERPExitNode()
|
||||
if err != nil {
|
||||
writeErrorJSON(w, err)
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(suggestedExitNodeID)
|
||||
}
|
||||
|
|
|
@ -2209,6 +2209,9 @@ const (
|
|||
|
||||
// NodeAttrsTailFSAccess enables accessing shares via TailFS.
|
||||
NodeAttrsTailFSAccess NodeCapability = "tailfs:access"
|
||||
|
||||
// NodeAttrSuggestExitNode enables using suggest exit node feature.
|
||||
NodeAttrSuggestExitNode NodeCapability = "suggest-exit-node"
|
||||
)
|
||||
|
||||
// SetDNSRequest is a request to add a DNS record.
|
||||
|
|
|
@ -3008,3 +3008,17 @@ func getPeerMTUsProbedMetric(mtu tstun.WireMTU) *clientmetric.Metric {
|
|||
mm, _ := metricRecvDiscoPeerMTUProbesByMTU.LoadOrInit(key, func() *clientmetric.Metric { return clientmetric.NewCounter(key) })
|
||||
return mm
|
||||
}
|
||||
|
||||
// GetLastNetcheckReport returns the last netcheck report.
|
||||
func (c *Conn) GetLastNetcheckReport(ctx context.Context) *netcheck.Report {
|
||||
lastReport := c.lastNetCheckReport.Load()
|
||||
if lastReport == nil {
|
||||
nr, err := c.updateNetInfo(ctx)
|
||||
if err != nil {
|
||||
c.logf("magicsock.Conn.GetLastNetcheckReport: updateNetInfo: %v", err)
|
||||
return nil
|
||||
}
|
||||
return nr
|
||||
}
|
||||
return lastReport
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue