// Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause package cli import ( "bytes" "context" "encoding/json" "errors" "flag" "fmt" "net" "net/http" "net/netip" "os" "strconv" "strings" "github.com/peterbourgon/ff/v3/ffcli" "github.com/toqueteos/webbrowser" "golang.org/x/net/idna" "tailscale.com/ipn" "tailscale.com/ipn/ipnstate" "tailscale.com/net/interfaces" "tailscale.com/util/dnsname" ) var statusCmd = &ffcli.Command{ Name: "status", ShortUsage: "status [--active] [--web] [--json]", ShortHelp: "Show state of tailscaled and its connections", LongHelp: strings.TrimSpace(` JSON FORMAT Warning: this format has changed between releases and might change more in the future. For a description of the fields, see the "type Status" declaration at: https://github.com/tailscale/tailscale/blob/main/ipn/ipnstate/ipnstate.go (and be sure to select branch/tag that corresponds to the version of Tailscale you're running) `), Exec: runStatus, FlagSet: (func() *flag.FlagSet { fs := newFlagSet("status") fs.BoolVar(&statusArgs.json, "json", false, "output in JSON format (WARNING: format subject to change)") 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.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 for web mode; use port 0 for automatic") fs.BoolVar(&statusArgs.browser, "browser", true, "Open a browser in web mode") return fs })(), } var statusArgs struct { json bool // JSON output mode web bool // run webserver listen string // in web mode, webserver address to listen on, empty means auto browser bool // in web mode, whether to open browser active bool // in CLI mode, filter output to only peers with active sessions 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 { if len(args) > 0 { return errors.New("unexpected non-flag arguments to 'tailscale status'") } getStatus := localClient.Status if !statusArgs.peers { getStatus = localClient.StatusWithoutPeers } st, err := getStatus(ctx) if err != nil { return fixTailscaledConnectError(err) } if statusArgs.json { if statusArgs.active { for peer, ps := range st.Peer { if !ps.Active { delete(st.Peer, peer) } } } j, err := json.MarshalIndent(st, "", " ") if err != nil { return err } printf("%s", j) return nil } if statusArgs.web { ln, err := net.Listen("tcp", statusArgs.listen) if err != nil { return err } statusURL := interfaces.HTTPOfListener(ln) printf("Serving Tailscale status at %v ...\n", statusURL) go func() { <-ctx.Done() ln.Close() }() if statusArgs.browser { go webbrowser.Open(statusURL) } err = http.Serve(ln, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.RequestURI != "/" { http.NotFound(w, r) return } st, err := localClient.Status(ctx) if err != nil { http.Error(w, err.Error(), 500) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") st.WriteHTML(w) })) if ctx.Err() != nil { return ctx.Err() } return err } printHealth := func() { printf("# Health check:\n") for _, m := range st.Health { printf("# - %s\n", m) } } description, ok := isRunningOrStarting(st) if !ok { // print health check information if we're in a weird state, as it might // provide context about why we're in that weird state. if len(st.Health) > 0 && (st.BackendState == ipn.Starting.String() || st.BackendState == ipn.NoState.String()) { printHealth() outln() } outln(description) os.Exit(1) } var buf bytes.Buffer f := func(format string, a ...any) { fmt.Fprintf(&buf, format, a...) } printPS := func(ps *ipnstate.PeerStatus) { f("%-15s %-20s %-12s %-7s ", firstIPString(ps.TailscaleIPs), dnsOrQuoteHostname(st, ps), ownerLogin(st, ps), ps.OS, ) relay := ps.Relay anyTraffic := ps.TxBytes != 0 || ps.RxBytes != 0 var offline string if !ps.Online { offline = "; offline" } if !ps.Active { if ps.ExitNode { f("idle; exit node" + offline) } else if ps.ExitNodeOption { f("idle; offers exit node" + offline) } else if anyTraffic { f("idle" + offline) } else if !ps.Online { f("offline") } else { f("-") } } else { f("active; ") if ps.ExitNode { f("exit node; ") } else if ps.ExitNodeOption { f("offers exit node; ") } if relay != "" && ps.CurAddr == "" { f("relay %q", relay) } else if ps.CurAddr != "" { f("direct %s", ps.CurAddr) } if !ps.Online { f("; offline") } } if anyTraffic { f(", tx %d rx %d", ps.TxBytes, ps.RxBytes) } f("\n") } if statusArgs.self && st.Self != nil { printPS(st.Self) } if statusArgs.peers { var peers []*ipnstate.PeerStatus for _, peer := range st.Peers() { ps := st.Peer[peer] if ps.ShareeNode { continue } peers = append(peers, ps) } ipnstate.SortPeers(peers) for _, ps := range peers { if statusArgs.active && !ps.Active { continue } printPS(ps) } } Stdout.Write(buf.Bytes()) if len(st.Health) > 0 { outln() printHealth() } printFunnelStatus(ctx) return nil } // printFunnelStatus prints the status of the funnel, if it's running. // It prints nothing if the funnel is not running. func printFunnelStatus(ctx context.Context) { sc, err := localClient.GetServeConfig(ctx) if err != nil { outln() printf("# Funnel:\n") printf("# - Unable to get Funnel status: %v\n", err) return } if !sc.IsFunnelOn() { return } outln() printf("# Funnel on:\n") for hp, on := range sc.AllowFunnel { if !on { // if present, should be on continue } sni, portStr, _ := net.SplitHostPort(string(hp)) p, _ := strconv.ParseUint(portStr, 10, 16) isTCP := sc.IsTCPForwardingOnPort(uint16(p)) url := "https://" if isTCP { url = "tcp://" } url += sni if isTCP || p != 443 { url += ":" + portStr } printf("# - %s\n", url) } } // isRunningOrStarting reports whether st is in state Running or Starting. // It also returns a description of the status suitable to display to a user. func isRunningOrStarting(st *ipnstate.Status) (description string, ok bool) { switch st.BackendState { default: return fmt.Sprintf("unexpected state: %s", st.BackendState), false case ipn.Stopped.String(): return "Tailscale is stopped.", false case ipn.NeedsLogin.String(): s := "Logged out." if st.AuthURL != "" { s += fmt.Sprintf("\nLog in at: %s", st.AuthURL) } return s, false case ipn.NeedsMachineAuth.String(): return "Machine is not yet authorized by tailnet admin.", false case ipn.Running.String(), ipn.Starting.String(): return st.BackendState, true } } func dnsOrQuoteHostname(st *ipnstate.Status, ps *ipnstate.PeerStatus) string { baseName := dnsname.TrimSuffix(ps.DNSName, st.MagicDNSSuffix) if baseName != "" { if strings.HasPrefix(baseName, "xn-") { if u, err := idna.ToUnicode(baseName); err == nil { return fmt.Sprintf("%s (%s)", baseName, u) } } return baseName } return fmt.Sprintf("(%q)", dnsname.SanitizeHostname(ps.HostName)) } 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 } func firstIPString(v []netip.Addr) string { if len(v) == 0 { return "" } return v[0].String() }