mirror of
https://github.com/tailscale/tailscale.git
synced 2024-11-26 19:45:35 +00:00
0d19f5d421
The log ID types were moved to a separate package so that code that only depend on log ID types do not need to link in the logic for the logtail client itself. Not all code need the logtail client. Signed-off-by: Joe Tsai <joetsai@digital-static.net>
387 lines
11 KiB
Go
387 lines
11 KiB
Go
// Copyright (c) Tailscale Inc & AUTHORS
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
// netlogfmt parses a stream of JSON log messages from stdin and
|
|
// formats the network traffic logs produced by "tailscale.com/wgengine/netlog"
|
|
// according to the schema in "tailscale.com/types/netlogtype.Message"
|
|
// in a more humanly readable format.
|
|
//
|
|
// Example usage:
|
|
//
|
|
// $ cat netlog.json | go run tailscale.com/cmd/netlogfmt
|
|
// =========================================================================================
|
|
// NodeID: n123456CNTRL
|
|
// Logged: 2022-10-13T20:23:10.165Z
|
|
// Window: 2022-10-13T20:23:09.644Z (5s)
|
|
// --------------------------------------------------- Tx[P/s] Tx[B/s] Rx[P/s] Rx[B/s]
|
|
// VirtualTraffic: 16.80 1.64Ki 11.20 1.03Ki
|
|
// TCP: 100.109.51.95:22 -> 100.85.80.41:42912 16.00 1.59Ki 10.40 1008.84
|
|
// TCP: 100.109.51.95:21291 -> 100.107.177.2:53133 0.40 27.60 0.40 24.20
|
|
// TCP: 100.109.51.95:21291 -> 100.107.177.2:53134 0.40 23.40 0.40 24.20
|
|
// PhysicalTraffic: 16.80 2.32Ki 11.20 1.48Ki
|
|
// 100.85.80.41 -> 192.168.0.101:41641 16.00 2.23Ki 10.40 1.40Ki
|
|
// 100.107.177.2 -> 192.168.0.100:41641 0.80 83.20 0.80 83.20
|
|
// =========================================================================================
|
|
package main
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"math"
|
|
"net/http"
|
|
"net/netip"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/dsnet/try"
|
|
jsonv2 "github.com/go-json-experiment/json"
|
|
"golang.org/x/exp/maps"
|
|
"golang.org/x/exp/slices"
|
|
"tailscale.com/types/logid"
|
|
"tailscale.com/types/netlogtype"
|
|
"tailscale.com/util/must"
|
|
)
|
|
|
|
var (
|
|
resolveNames = flag.Bool("resolve-names", false, "convert tailscale IP addresses to hostnames; must also specify --api-key and --tailnet-id")
|
|
apiKey = flag.String("api-key", "", "API key to query the Tailscale API with; see https://login.tailscale.com/admin/settings/keys")
|
|
tailnetName = flag.String("tailnet-name", "", "tailnet domain name to lookup devices in; see https://login.tailscale.com/admin/settings/general")
|
|
)
|
|
|
|
var namesByAddr map[netip.Addr]string
|
|
|
|
func main() {
|
|
flag.Parse()
|
|
if *resolveNames {
|
|
namesByAddr = mustMakeNamesByAddr()
|
|
}
|
|
|
|
// The logic handles a stream of arbitrary JSON.
|
|
// So long as a JSON object seems like a network log message,
|
|
// then this will unmarshal and print it.
|
|
if err := processStream(os.Stdin); err != nil {
|
|
if err == io.EOF {
|
|
return
|
|
}
|
|
log.Fatalf("processStream: %v", err)
|
|
}
|
|
}
|
|
|
|
func processStream(r io.Reader) (err error) {
|
|
defer try.Handle(&err)
|
|
dec := jsonv2.NewDecoder(os.Stdin)
|
|
for {
|
|
processValue(dec)
|
|
}
|
|
}
|
|
|
|
func processValue(dec *jsonv2.Decoder) {
|
|
switch dec.PeekKind() {
|
|
case '[':
|
|
processArray(dec)
|
|
case '{':
|
|
processObject(dec)
|
|
default:
|
|
try.E(dec.SkipValue())
|
|
}
|
|
}
|
|
|
|
func processArray(dec *jsonv2.Decoder) {
|
|
try.E1(dec.ReadToken()) // parse '['
|
|
for dec.PeekKind() != ']' {
|
|
processValue(dec)
|
|
}
|
|
try.E1(dec.ReadToken()) // parse ']'
|
|
}
|
|
|
|
func processObject(dec *jsonv2.Decoder) {
|
|
var hasTraffic bool
|
|
var rawMsg []byte
|
|
try.E1(dec.ReadToken()) // parse '{'
|
|
for dec.PeekKind() != '}' {
|
|
// Capture any members that could belong to a network log message.
|
|
switch name := try.E1(dec.ReadToken()); name.String() {
|
|
case "virtualTraffic", "subnetTraffic", "exitTraffic", "physicalTraffic":
|
|
hasTraffic = true
|
|
fallthrough
|
|
case "logtail", "nodeId", "logged", "start", "end":
|
|
if len(rawMsg) == 0 {
|
|
rawMsg = append(rawMsg, '{')
|
|
} else {
|
|
rawMsg = append(rawMsg[:len(rawMsg)-1], ',')
|
|
}
|
|
rawMsg = append(append(append(rawMsg, '"'), name.String()...), '"')
|
|
rawMsg = append(rawMsg, ':')
|
|
rawMsg = append(rawMsg, try.E1(dec.ReadValue())...)
|
|
rawMsg = append(rawMsg, '}')
|
|
default:
|
|
processValue(dec)
|
|
}
|
|
}
|
|
try.E1(dec.ReadToken()) // parse '}'
|
|
|
|
// If this appears to be a network log message, then unmarshal and print it.
|
|
if hasTraffic {
|
|
var msg message
|
|
try.E(jsonv2.Unmarshal(rawMsg, &msg))
|
|
printMessage(msg)
|
|
}
|
|
}
|
|
|
|
type message struct {
|
|
Logtail struct {
|
|
ID logid.PublicID `json:"id"`
|
|
Logged time.Time `json:"server_time"`
|
|
} `json:"logtail"`
|
|
Logged time.Time `json:"logged"`
|
|
netlogtype.Message
|
|
}
|
|
|
|
func printMessage(msg message) {
|
|
// Construct a table of network traffic per connection.
|
|
rows := [][7]string{{3: "Tx[P/s]", 4: "Tx[B/s]", 5: "Rx[P/s]", 6: "Rx[B/s]"}}
|
|
duration := msg.End.Sub(msg.Start)
|
|
addRows := func(heading string, traffic []netlogtype.ConnectionCounts) {
|
|
if len(traffic) == 0 {
|
|
return
|
|
}
|
|
slices.SortFunc(traffic, func(x, y netlogtype.ConnectionCounts) bool {
|
|
nx := x.TxPackets + x.TxBytes + x.RxPackets + x.RxBytes
|
|
ny := y.TxPackets + y.TxBytes + y.RxPackets + y.RxBytes
|
|
return nx > ny
|
|
})
|
|
var sum netlogtype.Counts
|
|
for _, cc := range traffic {
|
|
sum = sum.Add(cc.Counts)
|
|
}
|
|
rows = append(rows, [7]string{
|
|
0: heading + ":",
|
|
3: formatSI(float64(sum.TxPackets) / duration.Seconds()),
|
|
4: formatIEC(float64(sum.TxBytes) / duration.Seconds()),
|
|
5: formatSI(float64(sum.RxPackets) / duration.Seconds()),
|
|
6: formatIEC(float64(sum.RxBytes) / duration.Seconds()),
|
|
})
|
|
if len(traffic) == 1 && traffic[0].Connection.IsZero() {
|
|
return // this is already a summary counts
|
|
}
|
|
formatAddrPort := func(a netip.AddrPort) string {
|
|
if !a.IsValid() {
|
|
return ""
|
|
}
|
|
if name, ok := namesByAddr[a.Addr()]; ok {
|
|
if a.Port() == 0 {
|
|
return name
|
|
}
|
|
return name + ":" + strconv.Itoa(int(a.Port()))
|
|
}
|
|
if a.Port() == 0 {
|
|
return a.Addr().String()
|
|
}
|
|
return a.String()
|
|
}
|
|
for _, cc := range traffic {
|
|
row := [7]string{
|
|
0: " ",
|
|
1: formatAddrPort(cc.Src),
|
|
2: formatAddrPort(cc.Dst),
|
|
3: formatSI(float64(cc.TxPackets) / duration.Seconds()),
|
|
4: formatIEC(float64(cc.TxBytes) / duration.Seconds()),
|
|
5: formatSI(float64(cc.RxPackets) / duration.Seconds()),
|
|
6: formatIEC(float64(cc.RxBytes) / duration.Seconds()),
|
|
}
|
|
if cc.Proto > 0 {
|
|
row[0] += cc.Proto.String() + ":"
|
|
}
|
|
rows = append(rows, row)
|
|
}
|
|
}
|
|
addRows("VirtualTraffic", msg.VirtualTraffic)
|
|
addRows("SubnetTraffic", msg.SubnetTraffic)
|
|
addRows("ExitTraffic", msg.ExitTraffic)
|
|
addRows("PhysicalTraffic", msg.PhysicalTraffic)
|
|
|
|
// Compute the maximum width of each field.
|
|
var maxWidths [7]int
|
|
for _, row := range rows {
|
|
for i, col := range row {
|
|
if maxWidths[i] < len(col) && !(i == 0 && !strings.HasPrefix(col, " ")) {
|
|
maxWidths[i] = len(col)
|
|
}
|
|
}
|
|
}
|
|
var maxSum int
|
|
for _, n := range maxWidths {
|
|
maxSum += n
|
|
}
|
|
|
|
// Output a table of network traffic per connection.
|
|
line := make([]byte, 0, maxSum+len(" ")+len(" -> ")+4*len(" "))
|
|
line = appendRepeatByte(line, '=', cap(line))
|
|
fmt.Println(string(line))
|
|
if !msg.Logtail.ID.IsZero() {
|
|
fmt.Printf("LogID: %s\n", msg.Logtail.ID)
|
|
}
|
|
if msg.NodeID != "" {
|
|
fmt.Printf("NodeID: %s\n", msg.NodeID)
|
|
}
|
|
formatTime := func(t time.Time) string {
|
|
return t.In(time.Local).Format("2006-01-02 15:04:05.000")
|
|
}
|
|
switch {
|
|
case !msg.Logged.IsZero():
|
|
fmt.Printf("Logged: %s\n", formatTime(msg.Logged))
|
|
case !msg.Logtail.Logged.IsZero():
|
|
fmt.Printf("Logged: %s\n", formatTime(msg.Logtail.Logged))
|
|
}
|
|
fmt.Printf("Window: %s (%0.3fs)\n", formatTime(msg.Start), duration.Seconds())
|
|
for i, row := range rows {
|
|
line = line[:0]
|
|
isHeading := !strings.HasPrefix(row[0], " ")
|
|
for j, col := range row {
|
|
if isHeading && j == 0 {
|
|
col = "" // headings will be printed later
|
|
}
|
|
switch j {
|
|
case 0, 2: // left justified
|
|
line = append(line, col...)
|
|
line = appendRepeatByte(line, ' ', maxWidths[j]-len(col))
|
|
case 1, 3, 4, 5, 6: // right justified
|
|
line = appendRepeatByte(line, ' ', maxWidths[j]-len(col))
|
|
line = append(line, col...)
|
|
}
|
|
switch j {
|
|
case 0:
|
|
line = append(line, " "...)
|
|
case 1:
|
|
if row[1] == "" && row[2] == "" {
|
|
line = append(line, " "...)
|
|
} else {
|
|
line = append(line, " -> "...)
|
|
}
|
|
case 2, 3, 4, 5:
|
|
line = append(line, " "...)
|
|
}
|
|
}
|
|
switch {
|
|
case i == 0: // print dashed-line table heading
|
|
line = appendRepeatByte(line[:0], '-', maxWidths[0]+len(" ")+maxWidths[1]+len(" -> ")+maxWidths[2])[:cap(line)]
|
|
case isHeading:
|
|
copy(line[:], row[0])
|
|
}
|
|
fmt.Println(string(line))
|
|
}
|
|
}
|
|
|
|
func mustMakeNamesByAddr() map[netip.Addr]string {
|
|
switch {
|
|
case *apiKey == "":
|
|
log.Fatalf("--api-key must be specified with --resolve-names")
|
|
case *tailnetName == "":
|
|
log.Fatalf("--tailnet must be specified with --resolve-names")
|
|
}
|
|
|
|
// Query the Tailscale API for a list of devices in the tailnet.
|
|
const apiURL = "https://api.tailscale.com/api/v2"
|
|
req := must.Get(http.NewRequest("GET", apiURL+"/tailnet/"+*tailnetName+"/devices", nil))
|
|
req.Header.Add("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(*apiKey+":")))
|
|
resp := must.Get(http.DefaultClient.Do(req))
|
|
defer resp.Body.Close()
|
|
b := must.Get(io.ReadAll(resp.Body))
|
|
if resp.StatusCode != 200 {
|
|
log.Fatalf("http: %v: %s", http.StatusText(resp.StatusCode), b)
|
|
}
|
|
|
|
// Unmarshal the API response.
|
|
var m struct {
|
|
Devices []struct {
|
|
Name string `json:"name"`
|
|
Addrs []netip.Addr `json:"addresses"`
|
|
} `json:"devices"`
|
|
}
|
|
must.Do(json.Unmarshal(b, &m))
|
|
|
|
// Construct a unique mapping of Tailscale IP addresses to hostnames.
|
|
// For brevity, we start with the first segment of the name and
|
|
// use more segments until we find the shortest prefix that is unique
|
|
// for all names in the tailnet.
|
|
seen := make(map[string]bool)
|
|
namesByAddr := make(map[netip.Addr]string)
|
|
retry:
|
|
for i := 0; i < 10; i++ {
|
|
maps.Clear(seen)
|
|
maps.Clear(namesByAddr)
|
|
for _, d := range m.Devices {
|
|
name := fieldPrefix(d.Name, i)
|
|
if seen[name] {
|
|
continue retry
|
|
}
|
|
seen[name] = true
|
|
for _, a := range d.Addrs {
|
|
namesByAddr[a] = name
|
|
}
|
|
}
|
|
return namesByAddr
|
|
}
|
|
panic("unable to produce unique mapping of address to names")
|
|
}
|
|
|
|
// fieldPrefix returns the first n number of dot-separated segments.
|
|
//
|
|
// Example:
|
|
//
|
|
// fieldPrefix("foo.bar.baz", 0) returns ""
|
|
// fieldPrefix("foo.bar.baz", 1) returns "foo"
|
|
// fieldPrefix("foo.bar.baz", 2) returns "foo.bar"
|
|
// fieldPrefix("foo.bar.baz", 3) returns "foo.bar.baz"
|
|
// fieldPrefix("foo.bar.baz", 4) returns "foo.bar.baz"
|
|
func fieldPrefix(s string, n int) string {
|
|
s0 := s
|
|
for i := 0; i < n && len(s) > 0; i++ {
|
|
if j := strings.IndexByte(s, '.'); j >= 0 {
|
|
s = s[j+1:]
|
|
} else {
|
|
s = ""
|
|
}
|
|
}
|
|
return strings.TrimSuffix(s0[:len(s0)-len(s)], ".")
|
|
}
|
|
|
|
func appendRepeatByte(b []byte, c byte, n int) []byte {
|
|
for i := 0; i < n; i++ {
|
|
b = append(b, c)
|
|
}
|
|
return b
|
|
}
|
|
|
|
func formatSI(n float64) string {
|
|
switch n := math.Abs(n); {
|
|
case n < 1e3:
|
|
return fmt.Sprintf("%0.2f ", n/(1e0))
|
|
case n < 1e6:
|
|
return fmt.Sprintf("%0.2fk", n/(1e3))
|
|
case n < 1e9:
|
|
return fmt.Sprintf("%0.2fM", n/(1e6))
|
|
default:
|
|
return fmt.Sprintf("%0.2fG", n/(1e9))
|
|
}
|
|
}
|
|
|
|
func formatIEC(n float64) string {
|
|
switch n := math.Abs(n); {
|
|
case n < 1<<10:
|
|
return fmt.Sprintf("%0.2f ", n/(1<<0))
|
|
case n < 1<<20:
|
|
return fmt.Sprintf("%0.2fKi", n/(1<<10))
|
|
case n < 1<<30:
|
|
return fmt.Sprintf("%0.2fMi", n/(1<<20))
|
|
default:
|
|
return fmt.Sprintf("%0.2fGi", n/(1<<30))
|
|
}
|
|
}
|