@ -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,29 +55,96 @@ 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 )
// 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 {
// Unmarshal the log message containing network traffics.
var msg struct {
Logtail struct {
ID string ` json:"id" `
} ` json:"logtail" `
netlogtype . Message
processValue ( dec )
}
if err := dec . Decode ( & msg ) ; err != nil {
if err == io . EOF {
break
}
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 )
}
log . Fatalf ( "UnmarshalNext: %v" , err )
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 )
}
if len ( msg . VirtualTraffic ) + len ( msg . SubnetTraffic ) + len ( msg . ExitTraffic ) + len ( msg . PhysicalTraffic ) == 0 {
continue // nothing to print
}
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 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 )
@ -152,10 +225,22 @@ func main() {
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 )
if ! msg . Logtail . ID . IsZero ( ) {
fmt . Printf ( "LogID: %s\n" , msg . Logtail . ID )
}
if msg . NodeID != "" {
fmt . Printf ( "NodeID: %s\n" , msg . NodeID )
}
fmt . Printf ( "Time: %s (%s)\n" , msg . Start . Round ( time . Millisecond ) . Format ( time . RFC3339Nano ) , duration . Round ( time . Millisecond ) )
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 ] , " " )
@ -192,13 +277,10 @@ func main() {
}
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 == "" :