Update github.com/go-json-experiment/json to the latest version and fix the build in light of some breaking API changes. Updates #cleanup Signed-off-by: Joe Tsai <joetsai@digital-static.net>
		
			
				
	
	
		
			388 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
			
		
		
	
	
			388 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"
 | 
						|
	"slices"
 | 
						|
	"strconv"
 | 
						|
	"strings"
 | 
						|
	"time"
 | 
						|
 | 
						|
	"github.com/dsnet/try"
 | 
						|
	jsonv2 "github.com/go-json-experiment/json"
 | 
						|
	"github.com/go-json-experiment/json/jsontext"
 | 
						|
	"tailscale.com/types/logid"
 | 
						|
	"tailscale.com/types/netlogtype"
 | 
						|
	"tailscale.com/util/cmpx"
 | 
						|
	"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 := jsontext.NewDecoder(os.Stdin)
 | 
						|
	for {
 | 
						|
		processValue(dec)
 | 
						|
	}
 | 
						|
}
 | 
						|
 | 
						|
func processValue(dec *jsontext.Decoder) {
 | 
						|
	switch dec.PeekKind() {
 | 
						|
	case '[':
 | 
						|
		processArray(dec)
 | 
						|
	case '{':
 | 
						|
		processObject(dec)
 | 
						|
	default:
 | 
						|
		try.E(dec.SkipValue())
 | 
						|
	}
 | 
						|
}
 | 
						|
 | 
						|
func processArray(dec *jsontext.Decoder) {
 | 
						|
	try.E1(dec.ReadToken()) // parse '['
 | 
						|
	for dec.PeekKind() != ']' {
 | 
						|
		processValue(dec)
 | 
						|
	}
 | 
						|
	try.E1(dec.ReadToken()) // parse ']'
 | 
						|
}
 | 
						|
 | 
						|
func processObject(dec *jsontext.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) int {
 | 
						|
			nx := x.TxPackets + x.TxBytes + x.RxPackets + x.RxBytes
 | 
						|
			ny := y.TxPackets + y.TxBytes + y.RxPackets + y.RxBytes
 | 
						|
			return cmpx.Compare(ny, nx)
 | 
						|
		})
 | 
						|
		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++ {
 | 
						|
		clear(seen)
 | 
						|
		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))
 | 
						|
	}
 | 
						|
}
 |