exitnode.go 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247
  1. // Copyright (c) Tailscale Inc & AUTHORS
  2. // SPDX-License-Identifier: BSD-3-Clause
  3. package cli
  4. import (
  5. "cmp"
  6. "context"
  7. "errors"
  8. "flag"
  9. "fmt"
  10. "os"
  11. "slices"
  12. "strings"
  13. "text/tabwriter"
  14. "github.com/peterbourgon/ff/v3/ffcli"
  15. xmaps "golang.org/x/exp/maps"
  16. "tailscale.com/ipn/ipnstate"
  17. "tailscale.com/tailcfg"
  18. )
  19. var exitNodeCmd = &ffcli.Command{
  20. Name: "exit-node",
  21. ShortUsage: "exit-node [flags]",
  22. Subcommands: []*ffcli.Command{
  23. {
  24. Name: "list",
  25. ShortUsage: "exit-node list [flags]",
  26. ShortHelp: "Show exit nodes",
  27. Exec: runExitNodeList,
  28. FlagSet: (func() *flag.FlagSet {
  29. fs := newFlagSet("list")
  30. fs.StringVar(&exitNodeArgs.filter, "filter", "", "filter exit nodes by country")
  31. return fs
  32. })(),
  33. },
  34. },
  35. Exec: func(context.Context, []string) error {
  36. return errors.New("exit-node subcommand required; run 'tailscale exit-node -h' for details")
  37. },
  38. }
  39. var exitNodeArgs struct {
  40. filter string
  41. }
  42. // runExitNodeList returns a formatted list of exit nodes for a tailnet.
  43. // If the exit node has location and priority data, only the highest
  44. // priority node for each city location is shown to the user.
  45. // If the country location has more than one city, an 'Any' city
  46. // is returned for the country, which lists the highest priority
  47. // node in that country.
  48. // For countries without location data, each exit node is displayed.
  49. func runExitNodeList(ctx context.Context, args []string) error {
  50. if len(args) > 0 {
  51. return errors.New("unexpected non-flag arguments to 'tailscale exit-node list'")
  52. }
  53. getStatus := localClient.Status
  54. st, err := getStatus(ctx)
  55. if err != nil {
  56. return fixTailscaledConnectError(err)
  57. }
  58. var peers []*ipnstate.PeerStatus
  59. for _, ps := range st.Peer {
  60. if !ps.ExitNodeOption {
  61. // We only show exit nodes under the exit-node subcommand.
  62. continue
  63. }
  64. peers = append(peers, ps)
  65. }
  66. if len(peers) == 0 {
  67. return errors.New("no exit nodes found")
  68. }
  69. filteredPeers := filterFormatAndSortExitNodes(peers, exitNodeArgs.filter)
  70. if len(filteredPeers.Countries) == 0 && exitNodeArgs.filter != "" {
  71. return fmt.Errorf("no exit nodes found for %q", exitNodeArgs.filter)
  72. }
  73. w := tabwriter.NewWriter(os.Stdout, 10, 5, 5, ' ', 0)
  74. defer w.Flush()
  75. fmt.Fprintf(w, "\n %s\t%s\t%s\t%s\t%s\t", "IP", "HOSTNAME", "COUNTRY", "CITY", "STATUS")
  76. for _, country := range filteredPeers.Countries {
  77. for _, city := range country.Cities {
  78. for _, peer := range city.Peers {
  79. 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))
  80. }
  81. }
  82. }
  83. fmt.Fprintln(w)
  84. fmt.Fprintln(w)
  85. fmt.Fprintln(w, "# To use an exit node, use `tailscale set --exit-node=` followed by the hostname or IP")
  86. return nil
  87. }
  88. // peerStatus returns a string representing the current state of
  89. // a peer. If there is no notable state, a - is returned.
  90. func peerStatus(peer *ipnstate.PeerStatus) string {
  91. if !peer.Active {
  92. if peer.ExitNode {
  93. return "selected but offline"
  94. }
  95. if !peer.Online {
  96. return "offline"
  97. }
  98. }
  99. if peer.ExitNode {
  100. return "selected"
  101. }
  102. return "-"
  103. }
  104. type filteredExitNodes struct {
  105. Countries []*filteredCountry
  106. }
  107. type filteredCountry struct {
  108. Name string
  109. Cities []*filteredCity
  110. }
  111. type filteredCity struct {
  112. Name string
  113. Peers []*ipnstate.PeerStatus
  114. }
  115. const noLocationData = "-"
  116. // filterFormatAndSortExitNodes filters and sorts exit nodes into
  117. // alphabetical order, by country, city and then by priority if
  118. // present.
  119. // If an exit node has location data, and the country has more than
  120. // once city, an `Any` city is added to the country that contains the
  121. // highest priority exit node within that country.
  122. // For exit nodes without location data, their country fields are
  123. // defined as '-' to indicate that the data is not available.
  124. func filterFormatAndSortExitNodes(peers []*ipnstate.PeerStatus, filterBy string) filteredExitNodes {
  125. countries := make(map[string]*filteredCountry)
  126. cities := make(map[string]*filteredCity)
  127. for _, ps := range peers {
  128. if ps.Location == nil {
  129. ps.Location = &tailcfg.Location{
  130. Country: noLocationData,
  131. CountryCode: noLocationData,
  132. City: noLocationData,
  133. CityCode: noLocationData,
  134. }
  135. }
  136. if filterBy != "" && ps.Location.Country != filterBy {
  137. continue
  138. }
  139. co, coOK := countries[ps.Location.CountryCode]
  140. if !coOK {
  141. co = &filteredCountry{
  142. Name: ps.Location.Country,
  143. }
  144. countries[ps.Location.CountryCode] = co
  145. }
  146. ci, ciOK := cities[ps.Location.CityCode]
  147. if !ciOK {
  148. ci = &filteredCity{
  149. Name: ps.Location.City,
  150. }
  151. cities[ps.Location.CityCode] = ci
  152. co.Cities = append(co.Cities, ci)
  153. }
  154. ci.Peers = append(ci.Peers, ps)
  155. }
  156. filteredExitNodes := filteredExitNodes{
  157. Countries: xmaps.Values(countries),
  158. }
  159. for _, country := range filteredExitNodes.Countries {
  160. if country.Name == noLocationData {
  161. // Countries without location data should not
  162. // be filtered further.
  163. continue
  164. }
  165. var countryANYPeer []*ipnstate.PeerStatus
  166. for _, city := range country.Cities {
  167. sortPeersByPriority(city.Peers)
  168. countryANYPeer = append(countryANYPeer, city.Peers...)
  169. var reducedCityPeers []*ipnstate.PeerStatus
  170. for i, peer := range city.Peers {
  171. if i == 0 || peer.ExitNode {
  172. // We only return the highest priority peer and any peer that
  173. // is currently the active exit node.
  174. reducedCityPeers = append(reducedCityPeers, peer)
  175. }
  176. }
  177. city.Peers = reducedCityPeers
  178. }
  179. sortByCityName(country.Cities)
  180. sortPeersByPriority(countryANYPeer)
  181. if len(country.Cities) > 1 {
  182. // For countries with more than one city, we want to return the
  183. // option of the best peer for that country.
  184. country.Cities = append([]*filteredCity{
  185. {
  186. Name: "Any",
  187. Peers: []*ipnstate.PeerStatus{countryANYPeer[0]},
  188. },
  189. }, country.Cities...)
  190. }
  191. }
  192. sortByCountryName(filteredExitNodes.Countries)
  193. return filteredExitNodes
  194. }
  195. // sortPeersByPriority sorts a slice of PeerStatus
  196. // by location.Priority, in order of highest priority.
  197. func sortPeersByPriority(peers []*ipnstate.PeerStatus) {
  198. slices.SortStableFunc(peers, func(a, b *ipnstate.PeerStatus) int {
  199. return cmp.Compare(b.Location.Priority, a.Location.Priority)
  200. })
  201. }
  202. // sortByCityName sorts a slice of filteredCity alphabetically
  203. // by name. The '-' used to indicate no location data will always
  204. // be sorted to the front of the slice.
  205. func sortByCityName(cities []*filteredCity) {
  206. slices.SortStableFunc(cities, func(a, b *filteredCity) int { return strings.Compare(a.Name, b.Name) })
  207. }
  208. // sortByCountryName sorts a slice of filteredCountry alphabetically
  209. // by name. The '-' used to indicate no location data will always
  210. // be sorted to the front of the slice.
  211. func sortByCountryName(countries []*filteredCountry) {
  212. slices.SortStableFunc(countries, func(a, b *filteredCountry) int { return strings.Compare(a.Name, b.Name) })
  213. }