@ -4,13 +4,16 @@
// 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 | netlogfmt
// $ cat netlog.json | go run tailscale.com/cmd/ netlogfmt
// =========================================================================================
// Time: 2022-10-13T20:23:09.644Z (5s)
// 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
@ -37,8 +40,11 @@ import (
"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/logtail"
"tailscale.com/types/netlogtype"
"tailscale.com/util/must"
)
@ -49,156 +55,232 @@ var (
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 ( )
}
namesByAddr := mustMakeNamesByAddr ( )
dec := json . NewDecoder ( os . Stdin )
for {
// Unmarshal the log message containing network traffics.
var msg struct {
Logtail struct {
ID string ` json:"id" `
} ` json:"logtail" `
netlogtype . Message
// 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
}
if err := dec . Decode ( & msg ) ; err != nil {
if err == io . EOF {
break
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 ] , ',' )
}
log . Fatalf ( "UnmarshalNext: %v" , err )
}
if len ( msg . VirtualTraffic ) + len ( msg . SubnetTraffic ) + len ( msg . ExitTraffic ) + len ( msg . PhysicalTraffic ) == 0 {
continue // nothing to print
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 '}'
// 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
// 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 logtail . 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 ""
}
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 name , ok := namesByAddr [ a . Addr ( ) ] ; ok {
if a . Port ( ) == 0 {
return a . Addr ( ) . String ( )
return name
}
return a . String ( )
return name + ":" + strconv . Itoa ( int ( a . Port ( ) ) )
}
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 )
if a . Port ( ) == 0 {
return a . Addr ( ) . String ( )
}
return a . String ( )
}
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 )
}
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 )
}
var maxSum int
for _ , n := range maxWidths {
maxSum += n
}
}
addRows ( "VirtualTraffic" , msg . VirtualTraffic )
addRows ( "SubnetTraffic" , msg . SubnetTraffic )
addRows ( "ExitTraffic" , msg . ExitTraffic )
addRows ( "PhysicalTraffic" , msg . PhysicalTraffic )
// 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 != "" {
fmt . Printf ( "ID: %s\n" , msg . Logtail . ID )
// 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 )
}
}
fmt . Printf ( "Time: %s (%s)\n" , msg . Start . Round ( time . Millisecond ) . Format ( time . RFC3339Nano ) , duration . Round ( time . Millisecond ) )
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 , " " ... )
}
}
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 {
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 ] )
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 , " " ... )
}
fmt . Println ( string ( 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 ! * resolveNames :
return nil
case * apiKey == "" :
log . Fatalf ( "--api-key must be specified with --resolve-names" )
case * tailnetName == "" :