mirror of
https://github.com/tailscale/tailscale.git
synced 2025-03-01 19:51:58 +00:00

``` $ tailscale serve https:<port> <mount-point> <source> [off] $ tailscale serve tcp:<port> tcp://localhost:<local-port> [off] $ tailscale serve tls-terminated-tcp:<port> tcp://localhost:<local-port> [off] $ tailscale serve status [--json] $ tailscale funnel <serve-port> {on|off} $ tailscale funnel status [--json] ``` Fixes: #6674 Signed-off-by: Shayne Sweeney <shayne@tailscale.com>
318 lines
7.8 KiB
Go
318 lines
7.8 KiB
Go
// 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)
|
|
}
|
|
outln()
|
|
}
|
|
|
|
// 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 approved 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()
|
|
}
|