2020-03-26 05:57:46 +00:00
|
|
|
// 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"
|
2020-03-27 20:26:35 +00:00
|
|
|
"fmt"
|
|
|
|
"html"
|
|
|
|
"io"
|
2020-03-26 05:57:46 +00:00
|
|
|
"log"
|
|
|
|
"sort"
|
2020-03-27 20:26:35 +00:00
|
|
|
"strings"
|
2020-03-26 05:57:46 +00:00
|
|
|
"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
|
|
|
|
}
|
|
|
|
|
2020-03-27 20:26:35 +00:00
|
|
|
// SimpleHostName returns a potentially simplified version of ps.HostName for display purposes.
|
|
|
|
func (ps *PeerStatus) SimpleHostName() string {
|
|
|
|
n := ps.HostName
|
|
|
|
n = strings.TrimSuffix(n, ".local")
|
|
|
|
n = strings.TrimSuffix(n, ".localdomain")
|
|
|
|
return n
|
|
|
|
}
|
|
|
|
|
2020-03-26 05:57:46 +00:00
|
|
|
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)
|
|
|
|
}
|
2020-03-27 20:26:35 +00:00
|
|
|
|
|
|
|
func (st *Status) WriteHTML(w io.Writer) {
|
|
|
|
f := func(format string, args ...interface{}) { fmt.Fprintf(w, format, args...) }
|
|
|
|
|
2020-05-01 21:55:16 +00:00
|
|
|
f(`<!DOCTYPE html>
|
|
|
|
<html lang="en">
|
|
|
|
<head>
|
|
|
|
<title>Tailscale State</title>
|
|
|
|
<style>
|
|
|
|
body { font-family: monospace; }
|
|
|
|
.owner { text-decoration: underline; }
|
|
|
|
.tailaddr { font-style: italic; }
|
|
|
|
.acenter { text-align: center; }
|
|
|
|
.aright { text-align: right; }
|
|
|
|
table, th, td { border: 1px solid black; border-spacing : 0; border-collapse : collapse; }
|
|
|
|
thead { background-color: #FFA500; }
|
|
|
|
th, td { padding: 5px; }
|
|
|
|
td { vertical-align: top; }
|
|
|
|
table tbody tr:nth-child(even) td { background-color: #f5f5f5; }
|
|
|
|
</style>
|
|
|
|
</head>
|
|
|
|
<body>
|
|
|
|
<h1>Tailscale State</h1>
|
|
|
|
`)
|
|
|
|
|
2020-03-27 20:26:35 +00:00
|
|
|
//f("<p><b>logid:</b> %s</p>\n", logid)
|
|
|
|
//f("<p><b>opts:</b> <code>%s</code></p>\n", html.EscapeString(fmt.Sprintf("%+v", opts)))
|
|
|
|
|
2020-05-01 21:55:16 +00:00
|
|
|
f("<table>\n<thead>\n")
|
|
|
|
f("<tr><th>Peer</th><th>Node</th><th>Owner</th><th>Rx</th><th>Tx</th><th>Handshake</th><th>Endpoints</th></tr>\n")
|
|
|
|
f("</thead>\n<tbody>\n")
|
2020-03-27 20:26:35 +00:00
|
|
|
|
|
|
|
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 = "<i>offline</i>"
|
|
|
|
} else if !ps.KeepAlive {
|
|
|
|
hsAgo = "on demand"
|
|
|
|
} else {
|
|
|
|
hsAgo = "<b>pending</b>"
|
|
|
|
}
|
|
|
|
}
|
|
|
|
var owner string
|
|
|
|
if up, ok := st.User[ps.UserID]; ok {
|
|
|
|
owner = up.LoginName
|
|
|
|
if i := strings.Index(owner, "@"); i != -1 {
|
|
|
|
owner = owner[:i]
|
|
|
|
}
|
|
|
|
}
|
2020-05-01 21:55:16 +00:00
|
|
|
f("<tr><td>%s</td><td>%s %s<br><span class=\"tailaddr\">%s</span></td><td class=\"acenter owner\">%s</td><td class=\"aright\">%v</td><td class=\"aright\">%v</td><td class=\"aright\">%v</td>",
|
2020-03-27 20:26:35 +00:00
|
|
|
peer.ShortString(),
|
2020-05-01 21:55:16 +00:00
|
|
|
html.EscapeString(ps.SimpleHostName()),
|
|
|
|
osEmoji(ps.OS),
|
2020-03-27 20:26:35 +00:00
|
|
|
ps.TailAddr,
|
2020-05-01 21:55:16 +00:00
|
|
|
html.EscapeString(owner),
|
2020-03-27 20:26:35 +00:00
|
|
|
ps.RxBytes,
|
|
|
|
ps.TxBytes,
|
|
|
|
hsAgo,
|
|
|
|
)
|
2020-05-01 21:55:16 +00:00
|
|
|
f("<td class=\"aright\">")
|
2020-03-27 20:26:35 +00:00
|
|
|
match := false
|
|
|
|
for _, addr := range ps.Addrs {
|
|
|
|
if addr == ps.CurAddr {
|
|
|
|
match = true
|
2020-05-01 21:55:16 +00:00
|
|
|
f("🔗 <b>%s</b><br>", addr)
|
2020-03-27 20:26:35 +00:00
|
|
|
} else {
|
2020-05-01 21:55:16 +00:00
|
|
|
f("%s<br>", addr)
|
2020-03-27 20:26:35 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
if ps.CurAddr != "" && !match {
|
2020-05-01 21:55:16 +00:00
|
|
|
f("<b>%s</b> \xf0\x9f\xa7\xb3<br>", ps.CurAddr)
|
2020-03-27 20:26:35 +00:00
|
|
|
}
|
2020-05-01 21:55:16 +00:00
|
|
|
f("</td>") // end Addrs
|
2020-03-27 20:26:35 +00:00
|
|
|
|
|
|
|
f("</tr>\n")
|
|
|
|
}
|
2020-05-01 21:55:16 +00:00
|
|
|
f("</tbody>\n</table>\n")
|
|
|
|
f("</body>\n</html>\n")
|
2020-03-27 20:26:35 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
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 "👽"
|
|
|
|
}
|