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("Peer | Node | Rx | Tx | Handshake | Endpoints |
")
+
+ 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("%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(" |
") // end Addrs
+
+ f("\n")
+ }
+ 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)
}