From 35bdbeda9fa149bd6caf93b30d71dd767d16d4b6 Mon Sep 17 00:00:00 2001 From: Charlotte Brandhorst-Satzkorn Date: Thu, 13 Jul 2023 21:33:53 -0700 Subject: [PATCH] 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 option will be displayed, indicating the highest priority node within that country. Updates tailscale/corp#13025 Signed-off-by: Charlotte Brandhorst-Satzkorn --- cmd/tailscale/cli/cli.go | 1 + cmd/tailscale/cli/exitnode.go | 245 +++++++++++++++++++++++ cmd/tailscale/cli/exitnode_test.go | 308 +++++++++++++++++++++++++++++ cmd/tailscale/cli/status.go | 12 ++ cmd/tailscale/depaware.txt | 2 +- ipn/ipnlocal/local.go | 1 + ipn/ipnstate/ipnstate.go | 3 + 7 files changed, 571 insertions(+), 1 deletion(-) create mode 100644 cmd/tailscale/cli/exitnode.go create mode 100644 cmd/tailscale/cli/exitnode_test.go diff --git a/cmd/tailscale/cli/cli.go b/cmd/tailscale/cli/cli.go index 58d44645b..c1b94d695 100644 --- a/cmd/tailscale/cli/cli.go +++ b/cmd/tailscale/cli/cli.go @@ -129,6 +129,7 @@ change in the future. certCmd, netlockCmd, licensesCmd, + exitNodeCmd, }, FlagSet: rootfs, Exec: func(context.Context, []string) error { return flag.ErrHelp }, diff --git a/cmd/tailscale/cli/exitnode.go b/cmd/tailscale/cli/exitnode.go new file mode 100644 index 000000000..39a9cd5de --- /dev/null +++ b/cmd/tailscale/cli/exitnode.go @@ -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 }) +} diff --git a/cmd/tailscale/cli/exitnode_test.go b/cmd/tailscale/cli/exitnode_test.go new file mode 100644 index 000000000..d2329bda4 --- /dev/null +++ b/cmd/tailscale/cli/exitnode_test.go @@ -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) + } +} diff --git a/cmd/tailscale/cli/status.go b/cmd/tailscale/cli/status.go index 53fb99975..ba2215774 100644 --- a/cmd/tailscale/cli/status.go +++ b/cmd/tailscale/cli/status.go @@ -200,6 +200,8 @@ func runStatus(ctx context.Context, args []string) error { if statusArgs.self && st.Self != nil { printPS(st.Self) } + + locBasedExitNode := false if statusArgs.peers { var peers []*ipnstate.PeerStatus for _, peer := range st.Peers() { @@ -207,6 +209,12 @@ func runStatus(ctx context.Context, args []string) error { if ps.ShareeNode { continue } + if ps.Location != nil && ps.ExitNodeOption && !ps.ExitNode { + // Location based exit nodes are only shown with the + // `exit-node list` command. + locBasedExitNode = true + continue + } peers = append(peers, ps) } ipnstate.SortPeers(peers) @@ -218,6 +226,10 @@ func runStatus(ctx context.Context, args []string) error { } } Stdout.Write(buf.Bytes()) + if locBasedExitNode { + println() + println("# To see the full list of exit nodes, including location-based exit nodes, run `tailscale exit-node list` \n") + } if len(st.Health) > 0 { outln() printHealth() diff --git a/cmd/tailscale/depaware.txt b/cmd/tailscale/depaware.txt index 4b20a60b8..9384ba84b 100644 --- a/cmd/tailscale/depaware.txt +++ b/cmd/tailscale/depaware.txt @@ -161,7 +161,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep golang.org/x/crypto/pbkdf2 from software.sslmate.com/src/go-pkcs12 golang.org/x/crypto/salsa20/salsa from golang.org/x/crypto/nacl/box+ golang.org/x/exp/constraints from golang.org/x/exp/slices+ - golang.org/x/exp/maps from tailscale.com/types/views + golang.org/x/exp/maps from tailscale.com/types/views+ golang.org/x/exp/slices from tailscale.com/net/tsaddr+ golang.org/x/net/bpf from github.com/mdlayher/netlink+ golang.org/x/net/dns/dnsmessage from net+ diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index 813d01265..ac1679c66 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -747,6 +747,7 @@ func (b *LocalBackend) populatePeerStatusLocked(sb *ipnstate.StatusBuilder) { ShareeNode: p.Hostinfo.ShareeNode(), ExitNode: p.StableID != "" && p.StableID == exitNodeID, SSH_HostKeys: p.Hostinfo.SSH_HostKeys().AsSlice(), + Location: p.Hostinfo.Location(), } peerStatusFromNode(ps, p) diff --git a/ipn/ipnstate/ipnstate.go b/ipn/ipnstate/ipnstate.go index 35437ce19..1d1d28b6c 100644 --- a/ipn/ipnstate/ipnstate.go +++ b/ipn/ipnstate/ipnstate.go @@ -273,6 +273,8 @@ type PeerStatus struct { // KeyExpiry, if present, is the time at which the node key expired or // will expire. KeyExpiry *time.Time `json:",omitempty"` + + Location *tailcfg.Location `json:",omitempty"` } type StatusBuilder struct { @@ -457,6 +459,7 @@ func (sb *StatusBuilder) AddPeer(peer key.NodePublic, st *PeerStatus) { if t := st.KeyExpiry; t != nil { e.KeyExpiry = ptr.To(*t) } + e.Location = st.Location } type StatusUpdater interface {