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 {