Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ jobs:
-e GOFLAGS=-buildvcs=false \
"$BUILDER_IMAGE" \
sh -eux <<'SCRIPT'
go version

# types/netcap.pb.go is generated from netcap.proto and gitignored, so
# a clean checkout has no types package and every build fails with
# "undefined: Alert". protoc and the gogofaster plugin ship in the
Expand Down
42 changes: 4 additions & 38 deletions cmd/capture/webui/connections_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
stdio "io"
"log"
"net/http"
Expand Down Expand Up @@ -208,44 +207,8 @@ func filterConnectionsByIPVersion(connections []ConnectionSummary, ipVersionFilt
func readConnections(outDir string) ([]ConnectionSummary, error) {
filePath := filepath.Join(outDir, "Connection.ncap.gz")

// Check if file exists
if _, err := os.Stat(filePath); os.IsNotExist(err) {
log.Printf("[WebUI] Connection file not found: %s", filePath)
return []ConnectionSummary{}, nil
}

// Read Connection records
reader, err := NewAuditRecordReader(filePath)
if err != nil {
return nil, err
}
defer reader.Close()

// Read header
_, err = reader.ReadHeader()
if err != nil {
return nil, err
}

connections := make([]ConnectionSummary, 0)

// Read all records
for {
record, err := reader.NextRecord()
if err != nil {
if err == io.EOF {
break
}
log.Printf("[WebUI] Error reading Connection record: %v", err)
continue
}

// Type assert to Connection
conn, ok := record.(*types.Connection)
if !ok {
continue
}

err := visitAuditRecords(filePath, "Connection", func(conn *types.Connection) {
connections = append(connections, ConnectionSummary{
TimestampFirst: conn.TimestampFirst,
TimestampLast: conn.TimestampLast,
Expand Down Expand Up @@ -299,6 +262,9 @@ func readConnections(outDir string) ([]ConnectionSummary, error) {
// Community ID for cross-tool correlation
CommunityID: conn.CommunityID,
})
})
if err != nil {
return nil, err
}

// Sort by total size descending (or by timestamp - could be configurable)
Expand Down
43 changes: 4 additions & 39 deletions cmd/capture/webui/devices_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,8 @@ package webui

import (
"encoding/json"
"io"
"log"
"net/http"
"os"
"path/filepath"
"sort"

Expand Down Expand Up @@ -99,44 +97,8 @@ func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) {
func readDeviceProfiles(outDir string) ([]DeviceProfileSummary, error) {
filePath := filepath.Join(outDir, "DeviceProfile.ncap.gz")

// Check if file exists
if _, err := os.Stat(filePath); os.IsNotExist(err) {
log.Printf("[WebUI] DeviceProfile file not found: %s", filePath)
return []DeviceProfileSummary{}, nil
}

// Read DeviceProfile records
reader, err := NewAuditRecordReader(filePath)
if err != nil {
return nil, err
}
defer reader.Close()

// Read header
_, err = reader.ReadHeader()
if err != nil {
return nil, err
}

devices := make([]DeviceProfileSummary, 0)

// Read all records
for {
record, err := reader.NextRecord()
if err != nil {
if err == io.EOF {
break
}
log.Printf("[WebUI] Error reading DeviceProfile record: %v", err)
continue
}

// Type assert to DeviceProfile
deviceProfile, ok := record.(*types.DeviceProfile)
if !ok {
continue
}

err := visitAuditRecords(filePath, "DeviceProfile", func(deviceProfile *types.DeviceProfile) {
devices = append(devices, DeviceProfileSummary{
MacAddr: deviceProfile.MacAddr,
DeviceManufacturer: deviceProfile.DeviceManufacturer,
Expand All @@ -154,6 +116,9 @@ func readDeviceProfiles(outDir string) ([]DeviceProfileSummary, error) {
OS: deviceProfile.OS,
Roles: deviceProfile.Roles,
})
})
if err != nil {
return nil, err
}

// Sort by packet count descending
Expand Down
54 changes: 54 additions & 0 deletions cmd/capture/webui/reader.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,12 @@ package webui
import (
"compress/gzip"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"os"
"reflect"
"strings"

"github.com/gogo/protobuf/proto"
Expand Down Expand Up @@ -99,6 +103,56 @@ func (r *AuditRecordReader) NextRecord() (proto.Message, error) {
return msg, nil
}

// ErrAuditRecordTypeMismatch indicates that a decoded record is not the requested type.
var ErrAuditRecordTypeMismatch = errors.New("audit record type mismatch")

// NextAs reads a header-selected record and asserts its type, consuming it even on mismatch.
func (r *AuditRecordReader) NextAs[T proto.Message]() (T, error) {
var zero T
msg, err := r.NextRecord()
if err != nil {
return zero, err
}
record, ok := msg.(T)
if !ok {
return zero, fmt.Errorf("%w: got %T, want %v", ErrAuditRecordTypeMismatch, msg, reflect.TypeFor[T]())
}
return record, nil
}

func visitAuditRecords[T proto.Message](path, label string, visit func(T)) error {
if _, err := os.Stat(path); os.IsNotExist(err) {
log.Printf("[WebUI] %s file not found: %s", label, path)
return nil
}

reader, err := NewAuditRecordReader(path)
if err != nil {
return err
}
defer reader.Close()
if _, err := reader.ReadHeader(); err != nil {
return err
}

for {
record, err := reader.NextAs[T]()
if err != nil {
if err == io.EOF {
break
}
if errors.Is(err, ErrAuditRecordTypeMismatch) {
continue
}
// Preserve skip-on-error behavior; persistent I/O errors can repeat indefinitely.
log.Printf("[WebUI] Error reading %s record: %v", label, err)
continue
}
visit(record)
}
return nil
}

// NextAsJSON reads the next audit record and returns it as JSON
func (r *AuditRecordReader) NextAsJSON() (string, error) {
msg, err := r.NextRecord()
Expand Down
120 changes: 120 additions & 0 deletions cmd/capture/webui/reader_handlers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package webui

import (
"bytes"
"compress/gzip"
"errors"
"io"
"os"
"path/filepath"
"reflect"
"testing"

"github.com/gogo/protobuf/proto"

"github.com/dreadl0ck/netcap/internal/delimited"
"github.com/dreadl0ck/netcap/types"
)

func TestAuditRecordHandlerReaders(t *testing.T) {
for _, tc := range []struct {
name string
kind types.Type
records []proto.Message
read func(string) (any, error)
want any
}{
{"Connection", types.Type_NC_Connection, []proto.Message{
&types.Connection{SrcIP: "192.0.2.1", NumPackets: 42, TotalSize: 10},
&types.Connection{TotalSize: 30},
&types.Connection{TotalSize: 20},
}, func(dir string) (any, error) {
return readConnections(dir)
}, []ConnectionSummary{{TotalSize: 30}, {TotalSize: 20}, {SrcIP: "192.0.2.1", NumPackets: 42, TotalSize: 10}}},
{"Service", types.Type_NC_Service, []proto.Message{
&types.Service{Name: "https", Port: 443, BytesServer: 10},
&types.Service{BytesClient: 30},
&types.Service{BytesServer: 15, BytesClient: 5},
}, func(dir string) (any, error) {
return readServices(dir)
}, []ServiceSummary{{BytesClient: 30}, {BytesServer: 15, BytesClient: 5}, {Name: "https", Port: 443, BytesServer: 10}}},
{"DeviceProfile", types.Type_NC_DeviceProfile, []proto.Message{
&types.DeviceProfile{MacAddr: "00:11:22:33:44:55", NumPackets: 7, Bytes: 100},
&types.DeviceProfile{NumPackets: 30},
&types.DeviceProfile{NumPackets: 20},
}, func(dir string) (any, error) {
return readDeviceProfiles(dir)
}, []DeviceProfileSummary{{NumPackets: 30}, {NumPackets: 20}, {MacAddr: "00:11:22:33:44:55", NumPackets: 7, Bytes: 100}}},
} {
for _, scenario := range []string{"missing", "wrong-type", "header-only", "malformed-header", "invalid-gzip", "malformed-record", "valid"} {
t.Run(tc.name+"/"+scenario, func(t *testing.T) {
dir := t.TempDir()
if scenario != "missing" {
kind, records := tc.kind, tc.records
if scenario == "wrong-type" {
kind, records = types.Type_NC_Service, []proto.Message{&types.Service{Name: "wrong"}, &types.Service{Name: "also wrong"}}
if tc.kind == types.Type_NC_Service {
kind, records = types.Type_NC_Connection, []proto.Message{&types.Connection{SrcIP: "192.0.2.2"}, &types.Connection{}}
}
}
if scenario == "header-only" {
records = nil
}
var data bytes.Buffer
gz := gzip.NewWriter(&data)
writer := delimited.NewWriter(gz)
if scenario == "malformed-header" {
if err := writer.Put([]byte{0x80}); err != nil {
t.Fatal(err)
}
} else if err := writer.PutProto(&types.Header{Type: kind}); err != nil {
t.Fatal(err)
}
if scenario == "malformed-record" {
// Valid framing allows the next record to be read after protobuf decoding fails.
if err := writer.Put([]byte{0x80}); err != nil {
t.Fatal(err)
}
}
for _, record := range records {
if err := writer.PutProto(record); err != nil {
t.Fatal(err)
}
}
if err := gz.Close(); err != nil {
t.Fatal(err)
}
if scenario == "invalid-gzip" {
data.Reset()
data.WriteString("not a gzip file")
}
if err := os.WriteFile(filepath.Join(dir, tc.name+".ncap.gz"), data.Bytes(), 0600); err != nil {
t.Fatal(err)
}
}
got, err := tc.read(dir)
if scenario == "malformed-header" || scenario == "invalid-gzip" {
wantErr := io.ErrUnexpectedEOF
if scenario == "invalid-gzip" {
wantErr = gzip.ErrHeader
}
if !errors.Is(err, wantErr) || !reflect.ValueOf(got).IsNil() {
t.Fatalf("read = (%#v, %v), want (nil, %v)", got, err, wantErr)
}
return
}
if err != nil {
t.Fatal(err)
}
value := reflect.ValueOf(got)
if scenario == "valid" || scenario == "malformed-record" {
if !reflect.DeepEqual(got, tc.want) {
t.Fatalf("read = %#v, want %#v", got, tc.want)
}
} else if value.IsNil() || value.Len() != 0 {
t.Fatalf("read = %#v, want empty nonnil slice", got)
}
})
}
}
}
Loading
Loading