diff --git a/cmd/tailscale/netcheck.go b/cmd/tailscale/netcheck.go index ae5849c19..2ee1c23ca 100644 --- a/cmd/tailscale/netcheck.go +++ b/cmd/tailscale/netcheck.go @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. -package main // import "tailscale.com/cmd/tailscale" +package main import ( "context" @@ -10,11 +10,19 @@ import ( "log" "sort" + "github.com/peterbourgon/ff/v2/ffcli" "tailscale.com/derp/derpmap" "tailscale.com/net/dnscache" "tailscale.com/netcheck" ) +var netcheckCmd = &ffcli.Command{ + Name: "netcheck", + ShortUsage: "netcheck", + ShortHelp: "Print an analysis of local network conditions", + Exec: runNetcheck, +} + func runNetcheck(ctx context.Context, args []string) error { c := &netcheck.Client{ DERP: derpmap.Prod(), diff --git a/cmd/tailscale/status.go b/cmd/tailscale/status.go new file mode 100644 index 000000000..2b465838f --- /dev/null +++ b/cmd/tailscale/status.go @@ -0,0 +1,142 @@ +// Copyright (c) 2020 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 main + +import ( + "bytes" + "context" + "encoding/json" + "flag" + "fmt" + "log" + "net" + "net/http" + "os" + + "github.com/peterbourgon/ff/v2/ffcli" + "github.com/toqueteos/webbrowser" + "tailscale.com/ipn" + "tailscale.com/ipn/ipnstate" + "tailscale.com/net/interfaces" +) + +var statusCmd = &ffcli.Command{ + Name: "status", + ShortUsage: "status [-web] [-json]", + ShortHelp: "Show state of tailscaled and its connections", + Exec: runStatus, + FlagSet: (func() *flag.FlagSet { + fs := flag.NewFlagSet("status", flag.ExitOnError) + 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.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") + 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 +} + +func runStatus(ctx context.Context, args []string) error { + c, bc, ctx, cancel := connect(ctx) + defer cancel() + + ch := make(chan *ipnstate.Status, 1) + bc.SetNotifyCallback(func(n ipn.Notify) { + if n.ErrMessage != nil { + log.Fatal(*n.ErrMessage) + } + if n.Status != nil { + ch <- n.Status + } + }) + go pump(ctx, bc, c) + + getStatus := func() (*ipnstate.Status, error) { + bc.RequestStatus() + select { + case st := <-ch: + return st, nil + case <-ctx.Done(): + return nil, ctx.Err() + } + } + st, err := getStatus() + if err != nil { + return err + } + if statusArgs.json { + j, err := json.MarshalIndent(st, "", " ") + if err != nil { + return err + } + fmt.Printf("%s", j) + return nil + } + if statusArgs.web { + ln, err := net.Listen("tcp", statusArgs.listen) + if err != nil { + return err + } + statusURL := interfaces.HTTPOfListener(ln) + fmt.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 := getStatus() + 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 + } + + var buf bytes.Buffer + f := func(format string, a ...interface{}) { fmt.Fprintf(&buf, format, a...) } + for _, peer := range st.Peers() { + ps := st.Peer[peer] + f("%s %-7s %-15s %-18s tx=%8d rx=%8d ", + peer.ShortString(), + ps.OS, + ps.TailAddr, + ps.SimpleHostName(), + ps.TxBytes, + ps.RxBytes, + ) + for i, addr := range ps.Addrs { + if i != 0 { + f(", ") + } + if addr == ps.CurAddr { + f("*%s*", addr) + } else { + f("%s", addr) + } + } + f("\n") + } + os.Stdout.Write(buf.Bytes()) + return nil +} diff --git a/cmd/tailscale/tailscale.go b/cmd/tailscale/tailscale.go index 95b2b2817..57f7cbdf7 100644 --- a/cmd/tailscale/tailscale.go +++ b/cmd/tailscale/tailscale.go @@ -14,6 +14,7 @@ import ( "net" "os" "os/signal" + "runtime" "strings" "syscall" @@ -34,18 +35,8 @@ import ( // later, the global state key doesn't look like a username. const globalStateKey = "_daemon" -// pump receives backend messages on conn and pushes them into bc. -func pump(ctx context.Context, bc *ipn.BackendClient, conn net.Conn) { - defer log.Printf("Control connection done.\n") - defer conn.Close() - for ctx.Err() == nil { - msg, err := ipn.ReadMsg(conn) - if err != nil { - log.Printf("ReadMsg: %v\n", err) - break - } - bc.GotNotifyMsg(msg) - } +var rootArgs struct { + socket string } func main() { @@ -55,7 +46,6 @@ func main() { } upf := flag.NewFlagSet("up", flag.ExitOnError) - upf.StringVar(&upArgs.socket, "socket", paths.DefaultTailscaledSocket(), "path to tailscaled's unix socket") upf.StringVar(&upArgs.server, "login-server", "https://login.tailscale.com", "base URL of control server") upf.BoolVar(&upArgs.acceptRoutes, "accept-routes", false, "accept routes advertised by other Tailscale nodes") upf.BoolVar(&upArgs.noSingleRoutes, "no-single-routes", false, "don't install routes to single nodes") @@ -79,12 +69,8 @@ options are reset to their default. Exec: runUp, } - netcheckCmd := &ffcli.Command{ - Name: "netcheck", - ShortUsage: "netcheck", - ShortHelp: "Print an analysis of local network conditions", - Exec: runNetcheck, - } + rootfs := flag.NewFlagSet("tailscale", flag.ExitOnError) + rootfs.StringVar(&rootArgs.socket, "socket", paths.DefaultTailscaledSocket(), "path to tailscaled's unix socket") rootCmd := &ffcli.Command{ Name: "tailscale", @@ -97,8 +83,10 @@ change in the future. Subcommands: []*ffcli.Command{ upCmd, netcheckCmd, + statusCmd, }, - Exec: func(context.Context, []string) error { return flag.ErrHelp }, + FlagSet: rootfs, + Exec: func(context.Context, []string) error { return flag.ErrHelp }, } if err := rootCmd.ParseAndRun(context.Background(), os.Args[1:]); err != nil && err != flag.ErrHelp { @@ -106,14 +94,13 @@ change in the future. } } -var upArgs = struct { - socket string +var upArgs struct { server string acceptRoutes bool noSingleRoutes bool noPacketFilter bool advertiseRoutes string -}{} +} func runUp(ctx context.Context, args []string) error { if len(args) > 0 { @@ -142,25 +129,9 @@ func runUp(ctx context.Context, args []string) error { prefs.UsePacketFilter = !upArgs.noPacketFilter prefs.AdvertiseRoutes = adv - c, err := safesocket.Connect(upArgs.socket, 41112) - if err != nil { - log.Fatalf("Failed to connect to connect to tailscaled. (safesocket.Connect: %v)\n", err) - } - clientToServer := func(b []byte) { - ipn.WriteMsg(c, b) - } - - ctx, cancel := context.WithCancel(ctx) + c, bc, ctx, cancel := connect(ctx) defer cancel() - go func() { - interrupt := make(chan os.Signal, 1) - signal.Notify(interrupt, syscall.SIGINT, syscall.SIGTERM) - <-interrupt - c.Close() - }() - - bc := ipn.NewBackendClient(log.Printf, clientToServer) bc.SetPrefs(prefs) opts := ipn.Options{ StateKey: globalStateKey, @@ -197,3 +168,45 @@ func runUp(ctx context.Context, args []string) error { return nil } + +func connect(ctx context.Context) (net.Conn, *ipn.BackendClient, context.Context, context.CancelFunc) { + c, err := safesocket.Connect(rootArgs.socket, 41112) + if err != nil { + if runtime.GOOS != "windows" && rootArgs.socket == "" { + log.Fatalf("--socket cannot be empty") + } + log.Fatalf("Failed to connect to connect to tailscaled. (safesocket.Connect: %v)\n", err) + } + clientToServer := func(b []byte) { + ipn.WriteMsg(c, b) + } + + ctx, cancel := context.WithCancel(ctx) + + go func() { + interrupt := make(chan os.Signal, 1) + signal.Notify(interrupt, syscall.SIGINT, syscall.SIGTERM) + <-interrupt + c.Close() + cancel() + }() + + bc := ipn.NewBackendClient(log.Printf, clientToServer) + return c, bc, ctx, cancel +} + +// pump receives backend messages on conn and pushes them into bc. +func pump(ctx context.Context, bc *ipn.BackendClient, conn net.Conn) { + defer conn.Close() + for ctx.Err() == nil { + msg, err := ipn.ReadMsg(conn) + if err != nil { + if ctx.Err() != nil { + return + } + log.Printf("ReadMsg: %v\n", err) + break + } + bc.GotNotifyMsg(msg) + } +} diff --git a/go.mod b/go.mod index 0e037c689..e614ff432 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,7 @@ require ( github.com/tailscale/hujson v0.0.0-20190930033718-5098e564d9b3 // indirect github.com/tailscale/winipcfg-go v0.0.0-20200213045944-185b07f8233f github.com/tailscale/wireguard-go v0.0.0-20200325185614-bd634ffe2ded + github.com/toqueteos/webbrowser v1.2.0 golang.org/x/crypto v0.0.0-20200317142112-1b76d66859c6 golang.org/x/net v0.0.0-20200301022130-244492dfa37a // indirect golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d diff --git a/go.sum b/go.sum index a13b66c25..c0aa26d9e 100644 --- a/go.sum +++ b/go.sum @@ -133,6 +133,8 @@ github.com/tailscale/wireguard-go v0.0.0-20200320054525-e913b7c8517d h1:5Hc2ERvH github.com/tailscale/wireguard-go v0.0.0-20200320054525-e913b7c8517d/go.mod h1:JPm5cTfu1K+qDFRbiHy0sOlHUylYQbpl356sdYFD8V4= github.com/tailscale/wireguard-go v0.0.0-20200325185614-bd634ffe2ded h1:h5xaqGuzy578xFcIpbBIP1vWeFwggf5RC8PFBEldHr4= github.com/tailscale/wireguard-go v0.0.0-20200325185614-bd634ffe2ded/go.mod h1:JPm5cTfu1K+qDFRbiHy0sOlHUylYQbpl356sdYFD8V4= +github.com/toqueteos/webbrowser v1.2.0 h1:tVP/gpK69Fx+qMJKsLE7TD8LuGWPnEV71wBN9rrstGQ= +github.com/toqueteos/webbrowser v1.2.0/go.mod h1:XWoZq4cyp9WeUeak7w7LXRUQf1F1ATJMir8RTqb4ayM= github.com/ulikunitz/xz v0.5.6 h1:jGHAfXawEGZQ3blwU5wnWKQJvAraT7Ftq9EXjnXYgt8= github.com/ulikunitz/xz v0.5.6/go.mod h1:2bypXElzHzzJZwzH67Y6wb67pO62Rzfn7BSiF4ABRW8= github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo= diff --git a/ipn/backend.go b/ipn/backend.go index 19c809a0a..f47477207 100644 --- a/ipn/backend.go +++ b/ipn/backend.go @@ -8,6 +8,7 @@ import ( "time" "tailscale.com/control/controlclient" + "tailscale.com/ipn/ipnstate" "tailscale.com/tailcfg" "tailscale.com/types/empty" "tailscale.com/wgengine" @@ -45,15 +46,16 @@ type NetworkMap = controlclient.NetworkMap // that they have not changed. // They are JSON-encoded on the wire, despite the lack of struct tags. type Notify struct { - Version string // version number of IPN backend - ErrMessage *string // critical error message, if any - LoginFinished *empty.Message // event: non-nil when login process succeeded - State *State // current IPN state has changed - Prefs *Prefs // preferences were changed - NetMap *NetworkMap // new netmap received - Engine *EngineStatus // wireguard engine stats - BrowseToURL *string // UI should open a browser right now - BackendLogID *string // public logtail id used by backend + Version string // version number of IPN backend + ErrMessage *string // critical error message, if any + LoginFinished *empty.Message // event: non-nil when login process succeeded + State *State // current IPN state has changed + Prefs *Prefs // preferences were changed + NetMap *NetworkMap // new netmap received + Engine *EngineStatus // wireguard engine stats + Status *ipnstate.Status // full status + BrowseToURL *string // UI should open a browser right now + BackendLogID *string // public logtail id used by backend // type is mirrored in xcode/Shared/IPN.swift } @@ -126,6 +128,9 @@ type Backend interface { // counts. Connection events are emitted automatically without // polling. RequestEngineStatus() + // RequestStatus requests that a full Status update + // notification is sent. + RequestStatus() // FakeExpireAfter pretends that the current key is going to // expire after duration x. This is useful for testing GUIs to // make sure they react properly with keys that are going to diff --git a/ipn/fake_test.go b/ipn/fake_test.go index c0191cb00..dc9137358 100644 --- a/ipn/fake_test.go +++ b/ipn/fake_test.go @@ -7,6 +7,8 @@ package ipn import ( "log" "time" + + "tailscale.com/ipn/ipnstate" ) type FakeBackend struct { @@ -71,6 +73,10 @@ func (b *FakeBackend) RequestEngineStatus() { b.notify(Notify{Engine: &EngineStatus{}}) } +func (b *FakeBackend) RequestStatus() { + b.notify(Notify{Status: &ipnstate.Status{}}) +} + func (b *FakeBackend) FakeExpireAfter(x time.Duration) { b.notify(Notify{NetMap: &NetworkMap{}}) } diff --git a/ipn/handle.go b/ipn/handle.go index 2d05ee91d..3ac952b7e 100644 --- a/ipn/handle.go +++ b/ipn/handle.go @@ -161,6 +161,10 @@ func (h *Handle) RequestEngineStatus() { h.b.RequestEngineStatus() } +func (h *Handle) RequestStatus() { + h.b.RequestStatus() +} + func (h *Handle) FakeExpireAfter(x time.Duration) { h.b.FakeExpireAfter(x) } diff --git a/ipn/ipnserver/server.go b/ipn/ipnserver/server.go index 283f32006..0e71bb913 100644 --- a/ipn/ipnserver/server.go +++ b/ipn/ipnserver/server.go @@ -8,7 +8,6 @@ import ( "bufio" "context" "fmt" - "html" "log" "net" "net/http" @@ -120,7 +119,10 @@ func Run(rctx context.Context, logf logger.Logf, logid string, opts Options, e w if opts.DebugMux != nil { opts.DebugMux.HandleFunc("/debug/ipn", func(w http.ResponseWriter, r *http.Request) { - serveDebugHandler(w, r, logid, opts, b, e) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + st := b.Status() + // TODO(bradfitz): add LogID and opts to st? + st.WriteHTML(w) }) } @@ -311,101 +313,3 @@ func BabysitProc(ctx context.Context, args []string, logf logger.Logf) { } } } - -func serveDebugHandler(w http.ResponseWriter, r *http.Request, logid string, opts Options, b *ipn.LocalBackend, e wgengine.Engine) { - f := func(format string, args ...interface{}) { fmt.Fprintf(w, format, args...) } - w.Header().Set("Content-Type", "text/html; charset=utf-8") - - f(`
`) - f("logid: %s
\n", logid) - f("opts: %s
Peer | Node | Rx | Tx | Handshake | Endpoints |
---|---|---|---|---|---|
%s | %s %s %s | %v | %v | %v | ", - peer.ShortString(), - osEmoji(ps.OS)+" "+html.EscapeString(simplifyHostname(ps.HostName)), - html.EscapeString(owner), - ps.TailAddr, - ps.RxBytes, - ps.TxBytes, - hsAgo, - ) - f("")
- match := false
- for _, addr := range ps.Addrs {
- if addr == ps.CurAddr {
- match = true
- f("%s 🔗 \n", addr) - } else { - f("%s \n", addr) - } - } - if ps.CurAddr != "" && !match { - f("%s \xf0\x9f\xa7\xb3 \n", ps.CurAddr) - } - f(" |
logid: %s
\n", logid) + //f("opts: %s
Peer | Node | Rx | Tx | Handshake | Endpoints |
---|---|---|---|---|---|
%s | %s %s %s | %v | %v | %v | ", + peer.ShortString(), + osEmoji(ps.OS)+" "+html.EscapeString(ps.SimpleHostName()), + html.EscapeString(owner), + ps.TailAddr, + ps.RxBytes, + ps.TxBytes, + hsAgo, + ) + f("")
+ match := false
+ for _, addr := range ps.Addrs {
+ if addr == ps.CurAddr {
+ match = true
+ f("%s 🔗 \n", addr) + } else { + f("%s \n", addr) + } + } + if ps.CurAddr != "" && !match { + f("%s \xf0\x9f\xa7\xb3 \n", ps.CurAddr) + } + f(" |