cmd/tailscale: change formatting of "tailscale status"

* show DNS name over hostname, removing domain's common MagicDNS suffix.
  only show hostname if there's no DNS name.
  but still show shared devices' MagicDNS FQDN.

* remove nerdy low-level details by default: endpoints, DERP relay,
  public key.  They're available in JSON mode still for those who need
  them.

* only show endpoint or DERP relay when it's active with the goal of
  making debugging easier. (so it's easier for users to understand
  what's happening) The asterisks are gone.

* remove Tx/Rx numbers by default for idle peers; only show them when
  there's traffic.

* include peers' owner login names

* add CLI option to not show peers (matching --self=true, --peers= also
  defaults to true)

* sort by DNS/host name, not public key

* reorder columns
This commit is contained in:
Brad Fitzpatrick 2021-01-10 12:03:01 -08:00
parent c09d5a9e28
commit 5efb0a8bca
11 changed files with 167 additions and 84 deletions

View File

@ -14,6 +14,8 @@
"net" "net"
"net/http" "net/http"
"os" "os"
"sort"
"strings"
"time" "time"
"github.com/peterbourgon/ff/v2/ffcli" "github.com/peterbourgon/ff/v2/ffcli"
@ -21,6 +23,7 @@
"tailscale.com/ipn" "tailscale.com/ipn"
"tailscale.com/ipn/ipnstate" "tailscale.com/ipn/ipnstate"
"tailscale.com/net/interfaces" "tailscale.com/net/interfaces"
"tailscale.com/util/dnsname"
) )
var statusCmd = &ffcli.Command{ var statusCmd = &ffcli.Command{
@ -34,6 +37,7 @@
fs.BoolVar(&statusArgs.web, "web", false, "run webserver with HTML showing status") fs.BoolVar(&statusArgs.web, "web", false, "run webserver with HTML showing status")
fs.BoolVar(&statusArgs.active, "active", false, "filter output to only peers with active sessions (not applicable to web mode)") fs.BoolVar(&statusArgs.active, "active", false, "filter output to only peers with active sessions (not applicable to web mode)")
fs.BoolVar(&statusArgs.self, "self", true, "show status of local machine") fs.BoolVar(&statusArgs.self, "self", true, "show status of local machine")
fs.BoolVar(&statusArgs.peers, "peers", true, "show status of peers")
fs.StringVar(&statusArgs.listen, "listen", "127.0.0.1:8384", "listen address; use port 0 for automatic") fs.StringVar(&statusArgs.listen, "listen", "127.0.0.1:8384", "listen address; use port 0 for automatic")
fs.BoolVar(&statusArgs.browser, "browser", true, "Open a browser in web mode") fs.BoolVar(&statusArgs.browser, "browser", true, "Open a browser in web mode")
return fs return fs
@ -47,6 +51,7 @@
browser bool // in web mode, whether to open browser browser bool // in web mode, whether to open browser
active bool // in CLI mode, filter output to only peers with active sessions active bool // in CLI mode, filter output to only peers with active sessions
self bool // in CLI mode, show status of local machine self bool // in CLI mode, show status of local machine
peers bool // in CLI mode, show status of peer machines
} }
func runStatus(ctx context.Context, args []string) error { func runStatus(ctx context.Context, args []string) error {
@ -136,30 +141,30 @@ func runStatus(ctx context.Context, args []string) error {
f := func(format string, a ...interface{}) { fmt.Fprintf(&buf, format, a...) } f := func(format string, a ...interface{}) { fmt.Fprintf(&buf, format, a...) }
printPS := func(ps *ipnstate.PeerStatus) { printPS := func(ps *ipnstate.PeerStatus) {
active := peerActive(ps) active := peerActive(ps)
f("%s %-7s %-15s %-18s tx=%8d rx=%8d ", f("%-15s %-20s %-12s %-7s ",
ps.PublicKey.ShortString(),
ps.OS,
ps.TailAddr, ps.TailAddr,
ps.SimpleHostName(), dnsOrQuoteHostname(st, ps),
ps.TxBytes, ownerLogin(st, ps),
ps.RxBytes, ps.OS,
) )
relay := ps.Relay relay := ps.Relay
if active && relay != "" && ps.CurAddr == "" { anyTraffic := ps.TxBytes != 0 || ps.RxBytes != 0
relay = "*" + relay + "*" if !active {
} else { if anyTraffic {
relay = " " + relay f("idle")
}
f("%-6s", relay)
for i, addr := range ps.Addrs {
if i != 0 {
f(", ")
}
if addr == ps.CurAddr {
f("*%s*", addr)
} else { } else {
f("%s", addr) f("-")
} }
} else {
f("active; ")
if relay != "" && ps.CurAddr == "" {
f("relay %q", relay)
} else if ps.CurAddr != "" {
f("direct %s", ps.CurAddr)
}
}
if anyTraffic {
f(", tx %d rx %d", ps.TxBytes, ps.RxBytes)
} }
f("\n") f("\n")
} }
@ -167,16 +172,23 @@ func runStatus(ctx context.Context, args []string) error {
if statusArgs.self && st.Self != nil { if statusArgs.self && st.Self != nil {
printPS(st.Self) printPS(st.Self)
} }
for _, peer := range st.Peers() { if statusArgs.peers {
ps := st.Peer[peer] var peers []*ipnstate.PeerStatus
if ps.ShareeNode { for _, peer := range st.Peers() {
continue ps := st.Peer[peer]
if ps.ShareeNode {
continue
}
peers = append(peers, ps)
} }
active := peerActive(ps) sort.Slice(peers, func(i, j int) bool { return sortKey(peers[i]) < sortKey(peers[j]) })
if statusArgs.active && !active { for _, ps := range peers {
continue active := peerActive(ps)
if statusArgs.active && !active {
continue
}
printPS(ps)
} }
printPS(ps)
} }
os.Stdout.Write(buf.Bytes()) os.Stdout.Write(buf.Bytes())
return nil return nil
@ -188,3 +200,37 @@ func runStatus(ctx context.Context, args []string) error {
func peerActive(ps *ipnstate.PeerStatus) bool { func peerActive(ps *ipnstate.PeerStatus) bool {
return !ps.LastWrite.IsZero() && time.Since(ps.LastWrite) < 2*time.Minute return !ps.LastWrite.IsZero() && time.Since(ps.LastWrite) < 2*time.Minute
} }
func dnsOrQuoteHostname(st *ipnstate.Status, ps *ipnstate.PeerStatus) string {
if i := strings.Index(ps.DNSName, "."); i != -1 && dnsname.HasSuffix(ps.DNSName, st.MagicDNSSuffix) {
return ps.DNSName[:i]
}
if ps.DNSName != "" {
return ps.DNSName
}
return fmt.Sprintf("- (%q)", ps.SimpleHostName())
}
func sortKey(ps *ipnstate.PeerStatus) string {
if ps.DNSName != "" {
return ps.DNSName
}
if ps.HostName != "" {
return ps.HostName
}
return ps.TailAddr
}
func ownerLogin(st *ipnstate.Status, ps *ipnstate.PeerStatus) string {
if ps.UserID.IsZero() {
return "-"
}
u, ok := st.User[ps.UserID]
if !ok {
return fmt.Sprint(ps.UserID)
}
if i := strings.Index(u.LoginName, "@"); i != -1 {
return u.LoginName[:i+1]
}
return u.LoginName
}

View File

@ -74,6 +74,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
tailscale.com/types/strbuilder from tailscale.com/net/packet tailscale.com/types/strbuilder from tailscale.com/net/packet
tailscale.com/types/structs from tailscale.com/control/controlclient+ tailscale.com/types/structs from tailscale.com/control/controlclient+
tailscale.com/types/wgkey from tailscale.com/control/controlclient+ tailscale.com/types/wgkey from tailscale.com/control/controlclient+
tailscale.com/util/dnsname from tailscale.com/cmd/tailscale/cli+
LW tailscale.com/util/endian from tailscale.com/net/netns+ LW tailscale.com/util/endian from tailscale.com/net/netns+
tailscale.com/util/lineread from tailscale.com/control/controlclient+ tailscale.com/util/lineread from tailscale.com/control/controlclient+
tailscale.com/util/systemd from tailscale.com/control/controlclient+ tailscale.com/util/systemd from tailscale.com/control/controlclient+

View File

@ -82,6 +82,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com/types/strbuilder from tailscale.com/net/packet tailscale.com/types/strbuilder from tailscale.com/net/packet
tailscale.com/types/structs from tailscale.com/control/controlclient+ tailscale.com/types/structs from tailscale.com/control/controlclient+
tailscale.com/types/wgkey from tailscale.com/control/controlclient+ tailscale.com/types/wgkey from tailscale.com/control/controlclient+
tailscale.com/util/dnsname from tailscale.com/control/controlclient+
LW tailscale.com/util/endian from tailscale.com/net/netns+ LW tailscale.com/util/endian from tailscale.com/net/netns+
tailscale.com/util/lineread from tailscale.com/control/controlclient+ tailscale.com/util/lineread from tailscale.com/control/controlclient+
tailscale.com/util/pidowner from tailscale.com/ipn/ipnserver tailscale.com/util/pidowner from tailscale.com/ipn/ipnserver

View File

@ -18,6 +18,7 @@
"tailscale.com/tailcfg" "tailscale.com/tailcfg"
"tailscale.com/types/logger" "tailscale.com/types/logger"
"tailscale.com/types/wgkey" "tailscale.com/types/wgkey"
"tailscale.com/util/dnsname"
"tailscale.com/wgengine/filter" "tailscale.com/wgengine/filter"
) )
@ -56,7 +57,30 @@ type NetworkMap struct {
// TODO(crawshaw): Capabilities []tailcfg.Capability // TODO(crawshaw): Capabilities []tailcfg.Capability
} }
func (nm NetworkMap) String() string { // MagicDNSSuffix returns the domain's MagicDNS suffix, or empty if none.
// If non-empty, it will neither start nor end with a period.
func (nm *NetworkMap) MagicDNSSuffix() string {
searchPathUsedAsDNSSuffix := func(suffix string) bool {
if dnsname.HasSuffix(nm.Name, suffix) {
return true
}
for _, p := range nm.Peers {
if dnsname.HasSuffix(p.Name, suffix) {
return true
}
}
return false
}
for _, d := range nm.DNS.Domains {
if searchPathUsedAsDNSSuffix(d) {
return strings.Trim(d, ".")
}
}
return ""
}
func (nm *NetworkMap) String() string {
return nm.Concise() return nm.Concise()
} }

View File

@ -25,9 +25,10 @@
// Status represents the entire state of the IPN network. // Status represents the entire state of the IPN network.
type Status struct { type Status struct {
BackendState string BackendState string
TailscaleIPs []netaddr.IP // Tailscale IP(s) assigned to this node TailscaleIPs []netaddr.IP // Tailscale IP(s) assigned to this node
Self *PeerStatus Self *PeerStatus
MagicDNSSuffix string // e.g. "userfoo.tailscale.net" (no surrounding dots)
Peer map[key.Public]*PeerStatus Peer map[key.Public]*PeerStatus
User map[tailcfg.UserID]tailcfg.UserProfile User map[tailcfg.UserID]tailcfg.UserProfile
@ -103,6 +104,12 @@ func (sb *StatusBuilder) SetBackendState(v string) {
sb.st.BackendState = v sb.st.BackendState = v
} }
func (sb *StatusBuilder) SetMagicDNSSuffix(v string) {
sb.mu.Lock()
defer sb.mu.Unlock()
sb.st.MagicDNSSuffix = v
}
func (sb *StatusBuilder) Status() *Status { func (sb *StatusBuilder) Status() *Status {
sb.mu.Lock() sb.mu.Lock()
defer sb.mu.Unlock() defer sb.mu.Unlock()

View File

@ -201,6 +201,7 @@ func (b *LocalBackend) UpdateStatus(sb *ipnstate.StatusBuilder) {
// TODO: hostinfo, and its networkinfo // TODO: hostinfo, and its networkinfo
// TODO: EngineStatus copy (and deprecate it?) // TODO: EngineStatus copy (and deprecate it?)
if b.netMap != nil { if b.netMap != nil {
sb.SetMagicDNSSuffix(b.netMap.MagicDNSSuffix())
for id, up := range b.netMap.UserProfiles { for id, up := range b.netMap.UserProfiles {
sb.AddUser(id, up) sb.AddUser(id, up)
} }
@ -1232,28 +1233,10 @@ func (b *LocalBackend) authReconfig() {
// magicDNSRootDomains returns the subset of nm.DNS.Domains that are the search domains for MagicDNS. // magicDNSRootDomains returns the subset of nm.DNS.Domains that are the search domains for MagicDNS.
// Each entry has a trailing period. // Each entry has a trailing period.
func magicDNSRootDomains(nm *controlclient.NetworkMap) []string { func magicDNSRootDomains(nm *controlclient.NetworkMap) []string {
searchPathUsedAsDNSSuffix := func(suffix string) bool { if v := nm.MagicDNSSuffix(); v != "" {
if tsdns.NameHasSuffix(nm.Name, suffix) { return []string{strings.Trim(v, ".") + "."}
return true
}
for _, p := range nm.Peers {
if tsdns.NameHasSuffix(p.Name, suffix) {
return true
}
}
return false
} }
return nil
var ret []string
for _, d := range nm.DNS.Domains {
if searchPathUsedAsDNSSuffix(d) {
if !strings.HasSuffix(d, ".") {
d += "."
}
ret = append(ret, d)
}
}
return ret
} }
// routerConfig produces a router.Config from a wireguard config and IPN prefs. // routerConfig produces a router.Config from a wireguard config and IPN prefs.

19
util/dnsname/dnsname.go Normal file
View File

@ -0,0 +1,19 @@
// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Package dnsname contains string functions for working with DNS names.
package dnsname
import "strings"
// HasSuffix reports whether the provided DNS name ends with the
// component(s) in suffix, ignoring any trailing dots.
//
// If suffix is the empty string, HasSuffix always reports false.
func HasSuffix(name, suffix string) bool {
name = strings.TrimSuffix(name, ".")
suffix = strings.TrimSuffix(suffix, ".")
nameBase := strings.TrimSuffix(name, suffix)
return len(nameBase) < len(name) && strings.HasSuffix(nameBase, ".")
}

View File

@ -0,0 +1,28 @@
// Copyright (c) 2021 Tailscale Inc & AUTHORS All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package dnsname
import "testing"
func TestHasSuffix(t *testing.T) {
tests := []struct {
name, suffix string
want bool
}{
{"foo.com", "com", true},
{"foo.com.", "com", true},
{"foo.com.", "com.", true},
{"", "", false},
{"foo.com.", "", false},
{"foo.com.", "o.com", false},
}
for _, tt := range tests {
got := HasSuffix(tt.name, tt.suffix)
if got != tt.want {
t.Errorf("HasSuffix(%q, %q) = %v; want %v", tt.name, tt.suffix, got, tt.want)
}
}
}

View File

@ -2710,11 +2710,14 @@ func (c *Conn) UpdateStatus(sb *ipnstate.StatusBuilder) {
ss := &ipnstate.PeerStatus{ ss := &ipnstate.PeerStatus{
PublicKey: c.privateKey.Public(), PublicKey: c.privateKey.Public(),
Addrs: c.lastEndpoints, Addrs: c.lastEndpoints,
OS: version.OS(),
} }
if c.netMap != nil { if c.netMap != nil {
ss.HostName = c.netMap.Hostinfo.Hostname ss.HostName = c.netMap.Hostinfo.Hostname
ss.OS = version.OS()
ss.DNSName = c.netMap.Name ss.DNSName = c.netMap.Name
ss.UserID = c.netMap.User
} else {
ss.HostName, _ = os.Hostname()
} }
if c.derpMap != nil { if c.derpMap != nil {
derpRegion, ok := c.derpMap.Regions[c.myDerp] derpRegion, ok := c.derpMap.Regions[c.myDerp]

View File

@ -17,6 +17,7 @@
dns "golang.org/x/net/dns/dnsmessage" dns "golang.org/x/net/dns/dnsmessage"
"inet.af/netaddr" "inet.af/netaddr"
"tailscale.com/types/logger" "tailscale.com/types/logger"
"tailscale.com/util/dnsname"
) )
// maxResponseBytes is the maximum size of a response from a Resolver. // maxResponseBytes is the maximum size of a response from a Resolver.
@ -195,7 +196,7 @@ func (r *Resolver) Resolve(domain string, tp dns.Type) (netaddr.IP, dns.RCode, e
anyHasSuffix := false anyHasSuffix := false
for _, suffix := range dnsMap.rootDomains { for _, suffix := range dnsMap.rootDomains {
if NameHasSuffix(domain, suffix) { if dnsname.HasSuffix(domain, suffix) {
anyHasSuffix = true anyHasSuffix = true
break break
} }
@ -616,12 +617,3 @@ func (r *Resolver) respond(query []byte) ([]byte, error) {
return marshalResponse(resp) return marshalResponse(resp)
} }
// NameHasSuffix reports whether the provided DNS name ends with the
// component(s) in suffix, ignoring any trailing dots.
func NameHasSuffix(name, suffix string) bool {
name = strings.TrimSuffix(name, ".")
suffix = strings.TrimSuffix(suffix, ".")
nameBase := strings.TrimSuffix(name, suffix)
return len(nameBase) < len(name) && strings.HasSuffix(nameBase, ".")
}

View File

@ -797,24 +797,3 @@ func TestMarshalResponseFormatError(t *testing.T) {
} }
t.Logf("response: %q", v) t.Logf("response: %q", v)
} }
func TestNameHasSuffix(t *testing.T) {
tests := []struct {
name, suffix string
want bool
}{
{"foo.com", "com", true},
{"foo.com.", "com", true},
{"foo.com.", "com.", true},
{"", "", false},
{"foo.com.", "", false},
{"foo.com.", "o.com", false},
}
for _, tt := range tests {
got := NameHasSuffix(tt.name, tt.suffix)
if got != tt.want {
t.Errorf("NameHasSuffix(%q, %q) = %v; want %v", tt.name, tt.suffix, got, tt.want)
}
}
}