From 322499473e17cf86cef471b4faf494ca9328e278 Mon Sep 17 00:00:00 2001 From: Brad Fitzpatrick Date: Wed, 25 Mar 2020 22:57:46 -0700 Subject: [PATCH] cmd/tailscaled, wgengine, ipn: add /debug/ipn handler with world state Signed-off-by: Brad Fitzpatrick --- cmd/tailscaled/tailscaled.go | 13 ++- ipn/ipnserver/server.go | 110 ++++++++++++++++++++ ipn/ipnstate/ipnstate.go | 172 ++++++++++++++++++++++++++++++++ ipn/local.go | 45 +++++++++ types/key/key.go | 13 ++- wgengine/magicsock/magicsock.go | 35 +++++++ wgengine/userspace.go | 20 ++++ wgengine/watchdog.go | 4 + wgengine/wgengine.go | 7 ++ 9 files changed, 415 insertions(+), 4 deletions(-) create mode 100644 ipn/ipnstate/ipnstate.go diff --git a/cmd/tailscaled/tailscaled.go b/cmd/tailscaled/tailscaled.go index 82652bd6e..cc93745f6 100644 --- a/cmd/tailscaled/tailscaled.go +++ b/cmd/tailscaled/tailscaled.go @@ -62,8 +62,10 @@ func main() { log.Fatalf("--socket is required") } + var debugMux *http.ServeMux if *debug != "" { - go runDebugServer(*debug) + debugMux = newDebugMux() + go runDebugServer(debugMux, *debug) } var e wgengine.Engine @@ -84,6 +86,7 @@ func main() { AutostartStateKey: globalStateKey, LegacyConfigPath: paths.LegacyConfigPath, SurviveDisconnects: true, + DebugMux: debugMux, } err = ipnserver.Run(context.Background(), logf, pol.PublicID.String(), opts, e) if err != nil { @@ -98,14 +101,18 @@ func main() { pol.Shutdown(ctx) } -func runDebugServer(addr string) { +func newDebugMux() *http.ServeMux { mux := http.NewServeMux() mux.HandleFunc("/debug/pprof/", pprof.Index) mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline) mux.HandleFunc("/debug/pprof/profile", pprof.Profile) mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol) mux.HandleFunc("/debug/pprof/trace", pprof.Trace) - srv := http.Server{ + return mux +} + +func runDebugServer(mux *http.ServeMux, addr string) { + srv := &http.Server{ Addr: addr, Handler: mux, } diff --git a/ipn/ipnserver/server.go b/ipn/ipnserver/server.go index 5b258ae2d..283f32006 100644 --- a/ipn/ipnserver/server.go +++ b/ipn/ipnserver/server.go @@ -8,8 +8,10 @@ import ( "bufio" "context" "fmt" + "html" "log" "net" + "net/http" "os" "os/exec" "os/signal" @@ -56,6 +58,10 @@ type Options struct { // its existing state, and accepts new frontend connections. If // false, the server dumps its state and becomes idle. SurviveDisconnects bool + + // DebugMux, if non-nil, specifies an HTTP ServeMux in which + // to register a debug handler. + DebugMux *http.ServeMux } func pump(logf logger.Logf, ctx context.Context, bs *ipn.BackendServer, s net.Conn) { @@ -112,6 +118,12 @@ func Run(rctx context.Context, logf logger.Logf, logid string, opts Options, e w return zstd.NewReader(nil) }) + if opts.DebugMux != nil { + opts.DebugMux.HandleFunc("/debug/ipn", func(w http.ResponseWriter, r *http.Request) { + serveDebugHandler(w, r, logid, opts, b, e) + }) + } + var s net.Conn serverToClient := func(b []byte) { if s != nil { // TODO: racy access to s? @@ -299,3 +311,101 @@ 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("

IPN state

Run args

") + f("

logid: %s

\n", logid) + f("

opts: %s

\n", html.EscapeString(fmt.Sprintf("%+v", opts))) + + st := b.Status() + f("") + + now := time.Now() + + // The tailcontrol server rounds LastSeen to 10 minutes. So we + // declare that a longAgo seen time of 15 minutes means + // they're not connected. + longAgo := now.Add(-15 * time.Minute) + + for _, peer := range st.Peers() { + ps := st.Peer[peer] + var hsAgo string + if !ps.LastHandshake.IsZero() { + hsAgo = now.Sub(ps.LastHandshake).Round(time.Second).String() + " ago" + } else { + if ps.LastSeen.Before(longAgo) { + hsAgo = "offline" + } else if !ps.KeepAlive { + hsAgo = "on demand" + } else { + hsAgo = "pending" + } + } + var owner string + if up, ok := st.User[ps.UserID]; ok { + owner = up.LoginName + if i := strings.Index(owner, "@"); i != -1 { + owner = owner[:i] + } + } + f("", + peer.ShortString(), + osEmoji(ps.OS)+" "+html.EscapeString(simplifyHostname(ps.HostName)), + html.EscapeString(owner), + ps.TailAddr, + ps.RxBytes, + ps.TxBytes, + hsAgo, + ) + f("") // end Addrs + + f("\n") + } + f("
PeerNodeRxTxHandshakeEndpoints
%s%s
%s
%s
%v%v%v") + 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("
") +} + +func osEmoji(os string) string { + switch os { + case "linux": + return "🐧" + case "macOS": + return "🍎" + case "windows": + return "🖥️" + case "iOS": + return "📱" + case "android": + return "🤖" + case "freebsd": + return "👿" + case "openbsd": + return "🐡" + } + return "👽" +} + +func simplifyHostname(s string) string { + s = strings.TrimSuffix(s, ".local") + s = strings.TrimSuffix(s, ".localdomain") + return s +} diff --git a/ipn/ipnstate/ipnstate.go b/ipn/ipnstate/ipnstate.go new file mode 100644 index 000000000..d2f26aa20 --- /dev/null +++ b/ipn/ipnstate/ipnstate.go @@ -0,0 +1,172 @@ +// 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 ipnstate captures the entire state of the Tailscale network. +// +// It's a leaf package so ipn, wgengine, and magicsock can all depend on it. +package ipnstate + +import ( + "bytes" + "log" + "sort" + "sync" + "time" + + "tailscale.com/tailcfg" + "tailscale.com/types/key" +) + +// Status represents the entire state of the IPN network. +type Status struct { + BackendState string + Peer map[key.Public]*PeerStatus + User map[tailcfg.UserID]tailcfg.UserProfile +} + +func (s *Status) Peers() []key.Public { + kk := make([]key.Public, 0, len(s.Peer)) + for k := range s.Peer { + kk = append(kk, k) + } + sort.Slice(kk, func(i, j int) bool { return bytes.Compare(kk[i][:], kk[j][:]) < 0 }) + return kk +} + +type PeerStatus struct { + PublicKey key.Public + HostName string // HostInfo's Hostname (not a DNS name or necessarily unique) + OS string // HostInfo.OS + UserID tailcfg.UserID + + TailAddr string // Tailscale IP + + // Endpoints: + Addrs []string + CurAddr string // one of Addrs, or unique if roaming + + RxBytes int64 + TxBytes int64 + Created time.Time // time registered with tailcontrol + LastSeen time.Time // last seen to tailcontrol + LastHandshake time.Time // with local wireguard + KeepAlive bool + + // InNetworkMap means that this peer was seen in our latest network map. + // In theory, all of InNetworkMap and InMagicSock and InEngine should all be true. + InNetworkMap bool + + // InMagicSock means that this peer is being tracked by magicsock. + // In theory, all of InNetworkMap and InMagicSock and InEngine should all be true. + InMagicSock bool + + // InEngine means that this peer is tracked by the wireguard engine. + // In theory, all of InNetworkMap and InMagicSock and InEngine should all be true. + InEngine bool +} + +type StatusBuilder struct { + mu sync.Mutex + locked bool + st Status +} + +func (sb *StatusBuilder) Status() *Status { + sb.mu.Lock() + defer sb.mu.Unlock() + sb.locked = true + return &sb.st +} + +// AddUser adds a user profile to the status. +func (sb *StatusBuilder) AddUser(id tailcfg.UserID, up tailcfg.UserProfile) { + sb.mu.Lock() + defer sb.mu.Unlock() + if sb.locked { + log.Printf("[unexpected] ipnstate: AddUser after Locked") + return + } + + if sb.st.User == nil { + sb.st.User = make(map[tailcfg.UserID]tailcfg.UserProfile) + } + + sb.st.User[id] = up +} + +// AddPeer adds a peer node to the status. +// +// Its PeerStatus is mixed with any previous status already added. +func (sb *StatusBuilder) AddPeer(peer key.Public, st *PeerStatus) { + if st == nil { + panic("nil PeerStatus") + } + + sb.mu.Lock() + defer sb.mu.Unlock() + if sb.locked { + log.Printf("[unexpected] ipnstate: AddPeer after Locked") + return + } + + if sb.st.Peer == nil { + sb.st.Peer = make(map[key.Public]*PeerStatus) + } + e, ok := sb.st.Peer[peer] + if !ok { + sb.st.Peer[peer] = st + st.PublicKey = peer + return + } + + if v := st.HostName; v != "" { + e.HostName = v + } + if v := st.UserID; v != 0 { + e.UserID = v + } + if v := st.TailAddr; v != "" { + e.TailAddr = v + } + if v := st.OS; v != "" { + e.OS = st.OS + } + if v := st.Addrs; v != nil { + e.Addrs = v + } + if v := st.CurAddr; v != "" { + e.CurAddr = v + } + if v := st.RxBytes; v != 0 { + e.RxBytes = v + } + if v := st.TxBytes; v != 0 { + e.TxBytes = v + } + if v := st.LastHandshake; !v.IsZero() { + e.LastHandshake = v + } + if v := st.Created; !v.IsZero() { + e.Created = v + } + if v := st.LastSeen; !v.IsZero() { + e.LastSeen = v + } + if st.InNetworkMap { + e.InNetworkMap = true + } + if st.InMagicSock { + e.InMagicSock = true + } + if st.InEngine { + e.InEngine = true + } + if st.KeepAlive { + e.KeepAlive = true + } +} + +type StatusUpdater interface { + UpdateStatus(*StatusBuilder) +} diff --git a/ipn/local.go b/ipn/local.go index 9d3703640..944fd29ec 100644 --- a/ipn/local.go +++ b/ipn/local.go @@ -15,9 +15,11 @@ import ( "github.com/tailscale/wireguard-go/wgcfg" "tailscale.com/control/controlclient" + "tailscale.com/ipn/ipnstate" "tailscale.com/portlist" "tailscale.com/tailcfg" "tailscale.com/types/empty" + "tailscale.com/types/key" "tailscale.com/types/logger" "tailscale.com/version" "tailscale.com/wgengine" @@ -103,6 +105,49 @@ func (b *LocalBackend) Shutdown() { b.e.Wait() } +// Status returns the latest status of the Tailscale network from all the various components. +func (b *LocalBackend) Status() *ipnstate.Status { + sb := new(ipnstate.StatusBuilder) + b.UpdateStatus(sb) + return sb.Status() +} + +func (b *LocalBackend) UpdateStatus(sb *ipnstate.StatusBuilder) { + b.e.UpdateStatus(sb) + + b.mu.Lock() + defer b.mu.Unlock() + + // TODO: hostinfo, and its networkinfo + // TODO: EngineStatus copy (and deprecate it?) + if b.netMapCache != nil { + for id, up := range b.netMapCache.UserProfiles { + sb.AddUser(id, up) + } + for _, p := range b.netMapCache.Peers { + var lastSeen time.Time + if p.LastSeen != nil { + lastSeen = *p.LastSeen + } + var tailAddr string + if len(p.Addresses) > 0 { + tailAddr = strings.TrimSuffix(p.Addresses[0].String(), "/32") + } + sb.AddPeer(key.Public(p.Key), &ipnstate.PeerStatus{ + InNetworkMap: true, + UserID: p.User, + TailAddr: tailAddr, + HostName: p.Hostinfo.Hostname, + OS: p.Hostinfo.OS, + KeepAlive: p.KeepAlive, + Created: p.Created, + LastSeen: lastSeen, + }) + } + } + +} + // SetDecompressor sets a decompression function, which must be a zstd // reader. // diff --git a/types/key/key.go b/types/key/key.go index 6dae4d101..e000520bb 100644 --- a/types/key/key.go +++ b/types/key/key.go @@ -5,7 +5,11 @@ // Package key defines some types related to curve25519 keys. package key -import "golang.org/x/crypto/curve25519" +import ( + "encoding/base64" + + "golang.org/x/crypto/curve25519" +) // Private represents a curve25519 private key. type Private [32]byte @@ -24,6 +28,13 @@ type Public [32]byte // Public reports whether p is the zero value. func (p Public) IsZero() bool { return p == Public{} } +// ShortString returns the Tailscale conventional debug representation +// of a public key: the first five base64 digits of the key, in square +// brackets. +func (p Public) ShortString() string { + return "[" + base64.StdEncoding.EncodeToString(p[:])[:5] + "]" +} + // B32 returns k as the *[32]byte type that's used by the // golang.org/x/crypto packages. This allocates; it might // not be appropriate for performance-sensitive paths. diff --git a/wgengine/magicsock/magicsock.go b/wgengine/magicsock/magicsock.go index e4d6c4b4d..a98d69d2f 100644 --- a/wgengine/magicsock/magicsock.go +++ b/wgengine/magicsock/magicsock.go @@ -33,6 +33,7 @@ import ( "tailscale.com/derp" "tailscale.com/derp/derphttp" "tailscale.com/derp/derpmap" + "tailscale.com/ipn/ipnstate" "tailscale.com/net/dnscache" "tailscale.com/net/interfaces" "tailscale.com/netcheck" @@ -1898,3 +1899,37 @@ func sbPrintAddr(sb *strings.Builder, a net.UDPAddr) { } fmt.Fprintf(sb, ":%d", a.Port) } + +func (c *Conn) UpdateStatus(sb *ipnstate.StatusBuilder) { + c.mu.Lock() + defer c.mu.Unlock() + + for k, as := range c.addrsByKey { + ps := &ipnstate.PeerStatus{ + InMagicSock: true, + } + for i, ua := range as.addrs { + uaStr := udpAddrDebugString(ua) + ps.Addrs = append(ps.Addrs, uaStr) + if as.curAddr == i { + ps.CurAddr = uaStr + } + } + if as.roamAddr != nil { + ps.CurAddr = udpAddrDebugString(*as.roamAddr) + } + sb.AddPeer(k, ps) + } + + c.foreachActiveDerpSortedLocked(func(node int, ad activeDerp) { + // TODO(bradfitz): add to ipnstate.StatusBuilder + //f("
  • derp-%v: cr%v,wr%v
  • ", node, simpleDur(now.Sub(ad.createTime)), simpleDur(now.Sub(*ad.lastWrite))) + }) +} + +func udpAddrDebugString(ua net.UDPAddr) string { + if ua.IP.Equal(derpMagicIP) { + return fmt.Sprintf("derp-%d", ua.Port) + } + return ua.String() +} diff --git a/wgengine/userspace.go b/wgengine/userspace.go index e9e1c8416..30272903e 100644 --- a/wgengine/userspace.go +++ b/wgengine/userspace.go @@ -18,8 +18,10 @@ import ( "github.com/tailscale/wireguard-go/device" "github.com/tailscale/wireguard-go/tun" "github.com/tailscale/wireguard-go/wgcfg" + "tailscale.com/ipn/ipnstate" "tailscale.com/net/interfaces" "tailscale.com/tailcfg" + "tailscale.com/types/key" "tailscale.com/types/logger" "tailscale.com/wgengine/filter" "tailscale.com/wgengine/magicsock" @@ -666,3 +668,21 @@ func (e *userspaceEngine) SetNetInfoCallback(cb NetInfoCallback) { func (e *userspaceEngine) SetDERPEnabled(v bool) { e.magicConn.SetDERPEnabled(v) } + +func (e *userspaceEngine) UpdateStatus(sb *ipnstate.StatusBuilder) { + st, err := e.getStatus() + if err != nil { + e.logf("wgengine: getStatus: %v", err) + return + } + for _, ps := range st.Peers { + sb.AddPeer(key.Public(ps.NodeKey), &ipnstate.PeerStatus{ + RxBytes: int64(ps.RxBytes), + TxBytes: int64(ps.TxBytes), + LastHandshake: ps.LastHandshake, + InEngine: true, + }) + } + + e.magicConn.UpdateStatus(sb) +} diff --git a/wgengine/watchdog.go b/wgengine/watchdog.go index bb2f95ff2..cba9eab7a 100644 --- a/wgengine/watchdog.go +++ b/wgengine/watchdog.go @@ -11,6 +11,7 @@ import ( "time" "github.com/tailscale/wireguard-go/wgcfg" + "tailscale.com/ipn/ipnstate" "tailscale.com/wgengine/filter" ) @@ -74,6 +75,9 @@ func (e *watchdogEngine) SetFilter(filt *filter.Filter) { func (e *watchdogEngine) SetStatusCallback(cb StatusCallback) { e.watchdog("SetStatusCallback", func() { e.wrap.SetStatusCallback(cb) }) } +func (e *watchdogEngine) UpdateStatus(sb *ipnstate.StatusBuilder) { + e.watchdog("UpdateStatus", func() { e.wrap.UpdateStatus(sb) }) +} func (e *watchdogEngine) SetNetInfoCallback(cb NetInfoCallback) { e.watchdog("SetNetInfoCallback", func() { e.wrap.SetNetInfoCallback(cb) }) } diff --git a/wgengine/wgengine.go b/wgengine/wgengine.go index c6eef10d8..3974a1244 100644 --- a/wgengine/wgengine.go +++ b/wgengine/wgengine.go @@ -11,6 +11,7 @@ import ( "github.com/tailscale/wireguard-go/device" "github.com/tailscale/wireguard-go/tun" "github.com/tailscale/wireguard-go/wgcfg" + "tailscale.com/ipn/ipnstate" "tailscale.com/tailcfg" "tailscale.com/types/logger" "tailscale.com/wgengine/filter" @@ -29,6 +30,8 @@ type PeerStatus struct { } // Status is the Engine status. +// +// TODO(bradfitz): remove this, subset of ipnstate? Need to migrate users. type Status struct { Peers []PeerStatus LocalAddrs []string // TODO(crawshaw): []wgcfg.Endpoint? @@ -144,4 +147,8 @@ type Engine interface { // SetNetInfoCallback sets the function to call when a // new NetInfo summary is available. SetNetInfoCallback(NetInfoCallback) + + // UpdateStatus populates the network state using the provided + // status builder. + UpdateStatus(*ipnstate.StatusBuilder) }