mirror of https://github.com/tailscale/tailscale/
cli: introduce exit-node subcommand to list and filter exit nodes
This change introduces a new subcommand, `exit-node`, along with a subsubcommand of `list` and a `--filter` flag. Exit nodes without location data will continue to be displayed when `status` is used. Exit nodes with location data will only be displayed behind `exit-node list`, and in status if they are the active exit node. The `filter` flag can be used to filter exit nodes with location data by country. Exit nodes with Location.Priority data will have only the highest priority option for each country and city listed. For countries with multiple cities, a <Country> <Any> option will be displayed, indicating the highest priority node within that country. Updates tailscale/corp#13025 Signed-off-by: Charlotte Brandhorst-Satzkorn <charlotte@tailscale.com>pull/8728/head
parent
9d89e85db7
commit
35bdbeda9f
@ -0,0 +1,245 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
|
||||
"os"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/peterbourgon/ff/v3/ffcli"
|
||||
"golang.org/x/exp/maps"
|
||||
"golang.org/x/exp/slices"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
var exitNodeCmd = &ffcli.Command{
|
||||
Name: "exit-node",
|
||||
ShortUsage: "exit-node [flags]",
|
||||
Subcommands: []*ffcli.Command{
|
||||
{
|
||||
Name: "list",
|
||||
ShortUsage: "exit-node list [flags]",
|
||||
ShortHelp: "Show exit nodes",
|
||||
Exec: runExitNodeList,
|
||||
FlagSet: (func() *flag.FlagSet {
|
||||
fs := newFlagSet("list")
|
||||
fs.StringVar(&exitNodeArgs.filter, "filter", "", "filter exit nodes by country")
|
||||
return fs
|
||||
})(),
|
||||
},
|
||||
},
|
||||
Exec: func(context.Context, []string) error {
|
||||
return errors.New("exit-node subcommand required; run 'tailscale exit-node -h' for details")
|
||||
},
|
||||
}
|
||||
|
||||
var exitNodeArgs struct {
|
||||
filter string
|
||||
}
|
||||
|
||||
// runExitNodeList returns a formatted list of exit nodes for a tailnet.
|
||||
// If the exit node has location and priority data, only the highest
|
||||
// priority node for each city location is shown to the user.
|
||||
// If the country location has more than one city, an 'Any' city
|
||||
// is returned for the country, which lists the highest priority
|
||||
// node in that country.
|
||||
// For countries without location data, each exit node is displayed.
|
||||
func runExitNodeList(ctx context.Context, args []string) error {
|
||||
if len(args) > 0 {
|
||||
return errors.New("unexpected non-flag arguments to 'tailscale exit-node list'")
|
||||
}
|
||||
getStatus := localClient.Status
|
||||
st, err := getStatus(ctx)
|
||||
if err != nil {
|
||||
return fixTailscaledConnectError(err)
|
||||
}
|
||||
|
||||
var peers []*ipnstate.PeerStatus
|
||||
for _, ps := range st.Peer {
|
||||
if !ps.ExitNodeOption {
|
||||
// We only show location based exit nodes.
|
||||
continue
|
||||
}
|
||||
|
||||
peers = append(peers, ps)
|
||||
}
|
||||
|
||||
if len(peers) == 0 {
|
||||
return errors.New("no exit nodes found")
|
||||
}
|
||||
|
||||
filteredPeers := filterFormatAndSortExitNodes(peers, exitNodeArgs.filter)
|
||||
|
||||
if len(filteredPeers.Countries) == 0 && exitNodeArgs.filter != "" {
|
||||
return fmt.Errorf("no exit nodes found for %q", exitNodeArgs.filter)
|
||||
}
|
||||
|
||||
w := tabwriter.NewWriter(os.Stdout, 10, 5, 5, ' ', 0)
|
||||
defer w.Flush()
|
||||
fmt.Fprintf(w, "\n %s\t%s\t%s\t%s\t%s\t", "IP", "HOSTNAME", "COUNTRY", "CITY", "STATUS")
|
||||
for _, country := range filteredPeers.Countries {
|
||||
for _, city := range country.Cities {
|
||||
for _, peer := range city.Peers {
|
||||
|
||||
fmt.Fprintf(w, "\n %s\t%s\t%s\t%s\t%s\t", peer.TailscaleIPs[0], strings.Trim(peer.DNSName, "."), country.Name, city.Name, peerStatus(peer))
|
||||
}
|
||||
}
|
||||
}
|
||||
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")
|
||||
|
||||
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 {
|
||||
if !peer.Active {
|
||||
if peer.ExitNode {
|
||||
return "selected but offline"
|
||||
}
|
||||
if !peer.Online {
|
||||
return "offline"
|
||||
}
|
||||
}
|
||||
|
||||
if peer.ExitNode {
|
||||
return "selected"
|
||||
}
|
||||
|
||||
return "-"
|
||||
}
|
||||
|
||||
type filteredExitNodes struct {
|
||||
Countries []*filteredCountry
|
||||
}
|
||||
|
||||
type filteredCountry struct {
|
||||
Name string
|
||||
Cities []*filteredCity
|
||||
}
|
||||
|
||||
type filteredCity struct {
|
||||
Name string
|
||||
Peers []*ipnstate.PeerStatus
|
||||
}
|
||||
|
||||
const noLocationData = "-"
|
||||
|
||||
// filterFormatAndSortExitNodes filters and sorts exit nodes into
|
||||
// alphabetical order, by country, city and then by priority if
|
||||
// present.
|
||||
// If an exit node has location data, and the country has more than
|
||||
// once city, an `Any` city is added to the country that contains the
|
||||
// highest priority exit node within that country.
|
||||
// For exit nodes without location data, their country fields are
|
||||
// defined as '-' to indicate that the data is not available.
|
||||
func filterFormatAndSortExitNodes(peers []*ipnstate.PeerStatus, filterBy string) filteredExitNodes {
|
||||
countries := make(map[string]*filteredCountry)
|
||||
cities := make(map[string]*filteredCity)
|
||||
for _, ps := range peers {
|
||||
if ps.Location == nil {
|
||||
ps.Location = &tailcfg.Location{
|
||||
Country: noLocationData,
|
||||
CountryCode: noLocationData,
|
||||
City: noLocationData,
|
||||
CityCode: noLocationData,
|
||||
}
|
||||
}
|
||||
|
||||
if filterBy != "" && ps.Location.Country != filterBy {
|
||||
continue
|
||||
}
|
||||
|
||||
co, coOK := countries[ps.Location.CountryCode]
|
||||
if !coOK {
|
||||
co = &filteredCountry{
|
||||
Name: ps.Location.Country,
|
||||
}
|
||||
countries[ps.Location.CountryCode] = co
|
||||
|
||||
}
|
||||
|
||||
ci, ciOK := cities[ps.Location.CityCode]
|
||||
if !ciOK {
|
||||
ci = &filteredCity{
|
||||
Name: ps.Location.City,
|
||||
}
|
||||
cities[ps.Location.CityCode] = ci
|
||||
co.Cities = append(co.Cities, ci)
|
||||
}
|
||||
ci.Peers = append(ci.Peers, ps)
|
||||
}
|
||||
|
||||
filteredExitNodes := filteredExitNodes{
|
||||
Countries: maps.Values(countries),
|
||||
}
|
||||
|
||||
for _, country := range filteredExitNodes.Countries {
|
||||
if country.Name == noLocationData {
|
||||
// Countries without location data should not
|
||||
// be filtered further.
|
||||
continue
|
||||
}
|
||||
|
||||
var countryANYPeer []*ipnstate.PeerStatus
|
||||
for _, city := range country.Cities {
|
||||
sortPeersByPriority(city.Peers)
|
||||
countryANYPeer = append(countryANYPeer, city.Peers...)
|
||||
var reducedCityPeers []*ipnstate.PeerStatus
|
||||
for i, peer := range city.Peers {
|
||||
if i == 0 || peer.ExitNode {
|
||||
// We only return the highest priority peer and any peer that
|
||||
// is currently the active exit node.
|
||||
reducedCityPeers = append(reducedCityPeers, peer)
|
||||
}
|
||||
}
|
||||
city.Peers = reducedCityPeers
|
||||
}
|
||||
sortByCityName(country.Cities)
|
||||
sortPeersByPriority(countryANYPeer)
|
||||
|
||||
if len(country.Cities) > 1 {
|
||||
// For countries with more than one city, we want to return the
|
||||
// option of the best peer for that country.
|
||||
country.Cities = append([]*filteredCity{
|
||||
{
|
||||
Name: "Any",
|
||||
Peers: []*ipnstate.PeerStatus{countryANYPeer[0]},
|
||||
},
|
||||
}, country.Cities...)
|
||||
}
|
||||
}
|
||||
sortByCountryName(filteredExitNodes.Countries)
|
||||
|
||||
return filteredExitNodes
|
||||
}
|
||||
|
||||
// sortPeersByPriority sorts a slice of PeerStatus
|
||||
// by location.Priority, in order of highest priority.
|
||||
func sortPeersByPriority(peers []*ipnstate.PeerStatus) {
|
||||
slices.SortFunc(peers, func(a, b *ipnstate.PeerStatus) bool { return a.Location.Priority > b.Location.Priority })
|
||||
}
|
||||
|
||||
// sortByCityName sorts a slice of filteredCity alphabetically
|
||||
// by name. The '-' used to indicate no location data will always
|
||||
// be sorted to the front of the slice.
|
||||
func sortByCityName(cities []*filteredCity) {
|
||||
slices.SortFunc(cities, func(a, b *filteredCity) bool { return a.Name < b.Name })
|
||||
}
|
||||
|
||||
// sortByCountryName sorts a slice of filteredCountry alphabetically
|
||||
// by name. The '-' used to indicate no location data will always
|
||||
// be sorted to the front of the slice.
|
||||
func sortByCountryName(countries []*filteredCountry) {
|
||||
slices.SortFunc(countries, func(a, b *filteredCountry) bool { return a.Name < b.Name })
|
||||
}
|
@ -0,0 +1,308 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/google/go-cmp/cmp/cmpopts"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/key"
|
||||
)
|
||||
|
||||
func TestFilterFormatAndSortExitNodes(t *testing.T) {
|
||||
t.Run("without filter", func(t *testing.T) {
|
||||
ps := []*ipnstate.PeerStatus{
|
||||
{
|
||||
HostName: "everest-1",
|
||||
Location: &tailcfg.Location{
|
||||
Country: "Everest",
|
||||
CountryCode: "evr",
|
||||
City: "Hillary",
|
||||
CityCode: "hil",
|
||||
Priority: 100,
|
||||
},
|
||||
},
|
||||
{
|
||||
HostName: "lhotse-1",
|
||||
Location: &tailcfg.Location{
|
||||
Country: "Lhotse",
|
||||
CountryCode: "lho",
|
||||
City: "Fritz",
|
||||
CityCode: "fri",
|
||||
Priority: 200,
|
||||
},
|
||||
},
|
||||
{
|
||||
HostName: "lhotse-2",
|
||||
Location: &tailcfg.Location{
|
||||
Country: "Lhotse",
|
||||
CountryCode: "lho",
|
||||
City: "Fritz",
|
||||
CityCode: "fri",
|
||||
Priority: 100,
|
||||
},
|
||||
},
|
||||
{
|
||||
HostName: "nuptse-1",
|
||||
Location: &tailcfg.Location{
|
||||
Country: "Nuptse",
|
||||
CountryCode: "nup",
|
||||
City: "Walmsley",
|
||||
CityCode: "wal",
|
||||
Priority: 200,
|
||||
},
|
||||
},
|
||||
{
|
||||
HostName: "nuptse-2",
|
||||
Location: &tailcfg.Location{
|
||||
Country: "Nuptse",
|
||||
CountryCode: "nup",
|
||||
City: "Bonington",
|
||||
CityCode: "bon",
|
||||
Priority: 10,
|
||||
},
|
||||
},
|
||||
{
|
||||
HostName: "Makalu",
|
||||
},
|
||||
}
|
||||
|
||||
want := filteredExitNodes{
|
||||
Countries: []*filteredCountry{
|
||||
{
|
||||
Name: noLocationData,
|
||||
Cities: []*filteredCity{
|
||||
{
|
||||
Name: noLocationData,
|
||||
Peers: []*ipnstate.PeerStatus{
|
||||
ps[5],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Everest",
|
||||
Cities: []*filteredCity{
|
||||
{
|
||||
Name: "Hillary",
|
||||
Peers: []*ipnstate.PeerStatus{
|
||||
ps[0],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Lhotse",
|
||||
Cities: []*filteredCity{
|
||||
{
|
||||
Name: "Fritz",
|
||||
Peers: []*ipnstate.PeerStatus{
|
||||
ps[1],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Nuptse",
|
||||
Cities: []*filteredCity{
|
||||
{
|
||||
Name: "Any",
|
||||
Peers: []*ipnstate.PeerStatus{
|
||||
ps[3],
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Bonington",
|
||||
Peers: []*ipnstate.PeerStatus{
|
||||
ps[4],
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Walmsley",
|
||||
Peers: []*ipnstate.PeerStatus{
|
||||
ps[3],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := filterFormatAndSortExitNodes(ps, "")
|
||||
|
||||
if res := cmp.Diff(result.Countries, want.Countries, cmpopts.IgnoreUnexported(key.NodePublic{})); res != "" {
|
||||
t.Fatalf(res)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("with country filter", func(t *testing.T) {
|
||||
ps := []*ipnstate.PeerStatus{
|
||||
{
|
||||
HostName: "baker-1",
|
||||
Location: &tailcfg.Location{
|
||||
Country: "Pacific",
|
||||
CountryCode: "pst",
|
||||
City: "Baker",
|
||||
CityCode: "col",
|
||||
Priority: 100,
|
||||
},
|
||||
},
|
||||
{
|
||||
HostName: "hood-1",
|
||||
Location: &tailcfg.Location{
|
||||
Country: "Pacific",
|
||||
CountryCode: "pst",
|
||||
City: "Hood",
|
||||
CityCode: "hoo",
|
||||
Priority: 500,
|
||||
},
|
||||
},
|
||||
{
|
||||
HostName: "rainier-1",
|
||||
Location: &tailcfg.Location{
|
||||
Country: "Pacific",
|
||||
CountryCode: "pst",
|
||||
City: "Rainier",
|
||||
CityCode: "rai",
|
||||
Priority: 100,
|
||||
},
|
||||
},
|
||||
{
|
||||
HostName: "rainier-2",
|
||||
Location: &tailcfg.Location{
|
||||
Country: "Pacific",
|
||||
CountryCode: "pst",
|
||||
City: "Rainier",
|
||||
CityCode: "rai",
|
||||
Priority: 10,
|
||||
},
|
||||
},
|
||||
{
|
||||
HostName: "mitchell-1",
|
||||
Location: &tailcfg.Location{
|
||||
Country: "Atlantic",
|
||||
CountryCode: "atl",
|
||||
City: "Mitchell",
|
||||
CityCode: "mit",
|
||||
Priority: 200,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
want := filteredExitNodes{
|
||||
Countries: []*filteredCountry{
|
||||
{
|
||||
Name: "Pacific",
|
||||
Cities: []*filteredCity{
|
||||
{
|
||||
Name: "Any",
|
||||
Peers: []*ipnstate.PeerStatus{
|
||||
ps[1],
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Baker",
|
||||
Peers: []*ipnstate.PeerStatus{
|
||||
ps[0],
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Hood",
|
||||
Peers: []*ipnstate.PeerStatus{
|
||||
ps[1],
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Rainier",
|
||||
Peers: []*ipnstate.PeerStatus{
|
||||
ps[2],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := filterFormatAndSortExitNodes(ps, "Pacific")
|
||||
|
||||
if res := cmp.Diff(result.Countries, want.Countries, cmpopts.IgnoreUnexported(key.NodePublic{})); res != "" {
|
||||
t.Fatalf(res)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSortPeersByPriority(t *testing.T) {
|
||||
ps := []*ipnstate.PeerStatus{
|
||||
{
|
||||
Location: &tailcfg.Location{
|
||||
Priority: 100,
|
||||
},
|
||||
},
|
||||
{
|
||||
Location: &tailcfg.Location{
|
||||
Priority: 200,
|
||||
},
|
||||
},
|
||||
{
|
||||
Location: &tailcfg.Location{
|
||||
Priority: 300,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
sortPeersByPriority(ps)
|
||||
|
||||
if ps[0].Location.Priority != 300 {
|
||||
t.Fatalf("sortPeersByPriority did not order PeerStatus with highest priority as index 0, got %v, want %v", ps[0].Location.Priority, 300)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSortByCountryName(t *testing.T) {
|
||||
fc := []*filteredCountry{
|
||||
{
|
||||
Name: "Albania",
|
||||
},
|
||||
{
|
||||
Name: "Sweden",
|
||||
},
|
||||
{
|
||||
Name: "Zimbabwe",
|
||||
},
|
||||
{
|
||||
Name: noLocationData,
|
||||
},
|
||||
}
|
||||
|
||||
sortByCountryName(fc)
|
||||
|
||||
if fc[0].Name != noLocationData {
|
||||
t.Fatalf("sortByCountryName did not order countries by alphabetical order, got %v, want %v", fc[0].Name, noLocationData)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSortByCityName(t *testing.T) {
|
||||
fc := []*filteredCity{
|
||||
{
|
||||
Name: "Kingston",
|
||||
},
|
||||
{
|
||||
Name: "Goteborg",
|
||||
},
|
||||
{
|
||||
Name: "Squamish",
|
||||
},
|
||||
{
|
||||
Name: noLocationData,
|
||||
},
|
||||
}
|
||||
|
||||
sortByCityName(fc)
|
||||
|
||||
if fc[0].Name != noLocationData {
|
||||
t.Fatalf("sortByCityName did not order cities by alphabetical order, got %v, want %v", fc[0].Name, noLocationData)
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue