Add packet capture to debug bundle and CLI

This commit is contained in:
Viktor Liu
2026-04-15 07:26:13 +02:00
parent e804a705b7
commit e58c29d4f9
44 changed files with 4327 additions and 238 deletions

View File

@@ -17,6 +17,7 @@ ENV \
NETBIRD_BIN="/usr/local/bin/netbird" \ NETBIRD_BIN="/usr/local/bin/netbird" \
NB_LOG_FILE="console,/var/log/netbird/client.log" \ NB_LOG_FILE="console,/var/log/netbird/client.log" \
NB_DAEMON_ADDR="unix:///var/run/netbird.sock" \ NB_DAEMON_ADDR="unix:///var/run/netbird.sock" \
NB_ENABLE_CAPTURE="false" \
NB_ENTRYPOINT_SERVICE_TIMEOUT="30" NB_ENTRYPOINT_SERVICE_TIMEOUT="30"
ENTRYPOINT [ "/usr/local/bin/netbird-entrypoint.sh" ] ENTRYPOINT [ "/usr/local/bin/netbird-entrypoint.sh" ]

View File

@@ -23,6 +23,7 @@ ENV \
NB_DAEMON_ADDR="unix:///var/lib/netbird/netbird.sock" \ NB_DAEMON_ADDR="unix:///var/lib/netbird/netbird.sock" \
NB_LOG_FILE="console,/var/lib/netbird/client.log" \ NB_LOG_FILE="console,/var/lib/netbird/client.log" \
NB_DISABLE_DNS="true" \ NB_DISABLE_DNS="true" \
NB_ENABLE_CAPTURE="false" \
NB_ENTRYPOINT_SERVICE_TIMEOUT="30" NB_ENTRYPOINT_SERVICE_TIMEOUT="30"
ENTRYPOINT [ "/usr/local/bin/netbird-entrypoint.sh" ] ENTRYPOINT [ "/usr/local/bin/netbird-entrypoint.sh" ]

186
client/cmd/capture.go Normal file
View File

@@ -0,0 +1,186 @@
package cmd
import (
"context"
"fmt"
"io"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"github.com/spf13/cobra"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/durationpb"
"github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/util/capture"
)
var captureCmd = &cobra.Command{
Use: "capture",
Short: "Capture packets on the WireGuard interface",
Long: `Captures decrypted packets flowing through the WireGuard interface.
Default output is human-readable text. Use --pcap or --output for pcap binary.
Requires --enable-capture to be set at service install or reconfigure time.
Examples:
netbird debug capture
netbird debug capture host 100.64.0.1 and port 443
netbird debug capture tcp
netbird debug capture icmp
netbird debug capture src host 10.0.0.1 and dst port 80
netbird debug capture -o capture.pcap
netbird debug capture --pcap | tshark -r -
netbird debug capture --pcap | tcpdump -r - -n`,
Args: cobra.ArbitraryArgs,
RunE: runCapture,
}
func init() {
debugCmd.AddCommand(captureCmd)
captureCmd.Flags().Bool("pcap", false, "Force pcap binary output (default when --output is set)")
captureCmd.Flags().BoolP("verbose", "v", false, "Show seq/ack, TTL, window, total length")
captureCmd.Flags().Bool("ascii", false, "Print payload as ASCII after each packet (useful for HTTP)")
captureCmd.Flags().Uint32("snap-len", 0, "Max bytes per packet (0 = full)")
captureCmd.Flags().DurationP("duration", "d", 0, "Capture duration (0 = until interrupted)")
captureCmd.Flags().StringP("output", "o", "", "Write pcap to file instead of stdout")
}
func runCapture(cmd *cobra.Command, args []string) error {
conn, err := getClient(cmd)
if err != nil {
return err
}
defer func() {
if err := conn.Close(); err != nil {
cmd.PrintErrf(errCloseConnection, err)
}
}()
client := proto.NewDaemonServiceClient(conn)
req, err := buildCaptureRequest(cmd, args)
if err != nil {
return err
}
ctx, cancel := signal.NotifyContext(cmd.Context(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
stream, err := client.StartCapture(ctx, req)
if err != nil {
return handleCaptureError(err)
}
// First Recv is the empty acceptance message from the server. If the
// device is unavailable (kernel WG, not connected, capture disabled),
// the server returns an error instead.
if _, err := stream.Recv(); err != nil {
return handleCaptureError(err)
}
out, cleanup, err := captureOutput(cmd)
if err != nil {
return err
}
defer cleanup()
if req.TextOutput {
cmd.PrintErrf("Capturing packets... Press Ctrl+C to stop.\n")
} else {
cmd.PrintErrf("Capturing packets (pcap)... Press Ctrl+C to stop.\n")
}
return streamCapture(ctx, cmd, stream, out)
}
func buildCaptureRequest(cmd *cobra.Command, args []string) (*proto.StartCaptureRequest, error) {
req := &proto.StartCaptureRequest{}
if len(args) > 0 {
expr := strings.Join(args, " ")
if _, err := capture.ParseFilter(expr); err != nil {
return nil, fmt.Errorf("invalid filter: %w", err)
}
req.FilterExpr = expr
}
if snap, _ := cmd.Flags().GetUint32("snap-len"); snap > 0 {
req.SnapLen = snap
}
if d, _ := cmd.Flags().GetDuration("duration"); d != 0 {
if d < 0 {
return nil, fmt.Errorf("duration must not be negative")
}
req.Duration = durationpb.New(d)
}
req.Verbose, _ = cmd.Flags().GetBool("verbose")
req.Ascii, _ = cmd.Flags().GetBool("ascii")
outPath, _ := cmd.Flags().GetString("output")
forcePcap, _ := cmd.Flags().GetBool("pcap")
req.TextOutput = !forcePcap && outPath == ""
return req, nil
}
func streamCapture(ctx context.Context, cmd *cobra.Command, stream proto.DaemonService_StartCaptureClient, out io.Writer) error {
for {
pkt, err := stream.Recv()
if err != nil {
if ctx.Err() != nil {
cmd.PrintErrf("\nCapture stopped.\n")
return nil //nolint:nilerr // user interrupted
}
if err == io.EOF {
cmd.PrintErrf("\nCapture finished.\n")
return nil
}
return handleCaptureError(err)
}
if _, err := out.Write(pkt.GetData()); err != nil {
return fmt.Errorf("write output: %w", err)
}
}
}
// captureOutput returns the writer for capture data and a cleanup function.
func captureOutput(cmd *cobra.Command) (io.Writer, func(), error) {
outPath, _ := cmd.Flags().GetString("output")
if outPath == "" {
return os.Stdout, func() {
// no cleanup needed for stdout
}, nil
}
f, err := os.CreateTemp(filepath.Dir(outPath), filepath.Base(outPath)+".*.tmp")
if err != nil {
return nil, nil, fmt.Errorf("create output file: %w", err)
}
tmpPath := f.Name()
return f, func() {
if err := f.Close(); err != nil {
cmd.PrintErrf("close output file: %v\n", err)
}
if fi, err := os.Stat(tmpPath); err == nil && fi.Size() > 0 {
if err := os.Rename(tmpPath, outPath); err != nil {
cmd.PrintErrf("rename output file: %v\n", err)
} else {
cmd.PrintErrf("Wrote %s\n", outPath)
}
} else {
os.Remove(tmpPath)
}
}, nil
}
func handleCaptureError(err error) error {
if s, ok := status.FromError(err); ok {
return fmt.Errorf("%s", s.Message())
}
return err
}

View File

@@ -9,6 +9,7 @@ import (
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/durationpb"
"github.com/netbirdio/netbird/client/internal" "github.com/netbirdio/netbird/client/internal"
"github.com/netbirdio/netbird/client/internal/debug" "github.com/netbirdio/netbird/client/internal/debug"
@@ -239,11 +240,50 @@ func runForDuration(cmd *cobra.Command, args []string) error {
}() }()
} }
captureStarted := false
if wantCapture, _ := cmd.Flags().GetBool("capture"); wantCapture {
captureTimeout := duration + 30*time.Second
const maxBundleCapture = 10 * time.Minute
if captureTimeout > maxBundleCapture {
captureTimeout = maxBundleCapture
}
_, err := client.StartBundleCapture(cmd.Context(), &proto.StartBundleCaptureRequest{
Timeout: durationpb.New(captureTimeout),
})
if err != nil {
cmd.PrintErrf("Failed to start packet capture: %v\n", status.Convert(err).Message())
} else {
captureStarted = true
cmd.Println("Packet capture started.")
// Safety: always stop on exit, even if the normal stop below runs too.
defer func() {
if captureStarted {
stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if _, err := client.StopBundleCapture(stopCtx, &proto.StopBundleCaptureRequest{}); err != nil {
cmd.PrintErrf("Failed to stop packet capture: %v\n", err)
}
}
}()
}
}
if waitErr := waitForDurationOrCancel(cmd.Context(), duration, cmd); waitErr != nil { if waitErr := waitForDurationOrCancel(cmd.Context(), duration, cmd); waitErr != nil {
return waitErr return waitErr
} }
cmd.Println("\nDuration completed") cmd.Println("\nDuration completed")
if captureStarted {
stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if _, err := client.StopBundleCapture(stopCtx, &proto.StopBundleCaptureRequest{}); err != nil {
cmd.PrintErrf("Failed to stop packet capture: %v\n", err)
} else {
captureStarted = false
cmd.Println("Packet capture stopped.")
}
}
if cpuProfilingStarted { if cpuProfilingStarted {
if _, err := client.StopCPUProfile(cmd.Context(), &proto.StopCPUProfileRequest{}); err != nil { if _, err := client.StopCPUProfile(cmd.Context(), &proto.StopCPUProfileRequest{}); err != nil {
cmd.PrintErrf("Failed to stop CPU profiling: %v\n", err) cmd.PrintErrf("Failed to stop CPU profiling: %v\n", err)
@@ -416,4 +456,5 @@ func init() {
forCmd.Flags().BoolVarP(&systemInfoFlag, "system-info", "S", true, "Adds system information to the debug bundle") forCmd.Flags().BoolVarP(&systemInfoFlag, "system-info", "S", true, "Adds system information to the debug bundle")
forCmd.Flags().BoolVarP(&uploadBundleFlag, "upload-bundle", "U", false, "Uploads the debug bundle to a server") forCmd.Flags().BoolVarP(&uploadBundleFlag, "upload-bundle", "U", false, "Uploads the debug bundle to a server")
forCmd.Flags().StringVar(&uploadBundleURLFlag, "upload-bundle-url", types.DefaultBundleURL, "Service URL to get an URL to upload the debug bundle") forCmd.Flags().StringVar(&uploadBundleURLFlag, "upload-bundle-url", types.DefaultBundleURL, "Service URL to get an URL to upload the debug bundle")
forCmd.Flags().Bool("capture", false, "Capture packets during the debug duration and include in bundle")
} }

View File

@@ -75,6 +75,7 @@ var (
mtu uint16 mtu uint16
profilesDisabled bool profilesDisabled bool
updateSettingsDisabled bool updateSettingsDisabled bool
captureEnabled bool
rootCmd = &cobra.Command{ rootCmd = &cobra.Command{
Use: "netbird", Use: "netbird",

View File

@@ -44,6 +44,7 @@ func init() {
serviceCmd.AddCommand(runCmd, startCmd, stopCmd, restartCmd, svcStatusCmd, installCmd, uninstallCmd, reconfigureCmd, resetParamsCmd) serviceCmd.AddCommand(runCmd, startCmd, stopCmd, restartCmd, svcStatusCmd, installCmd, uninstallCmd, reconfigureCmd, resetParamsCmd)
serviceCmd.PersistentFlags().BoolVar(&profilesDisabled, "disable-profiles", false, "Disables profiles feature. If enabled, the client will not be able to change or edit any profile. To persist this setting, use: netbird service install --disable-profiles") serviceCmd.PersistentFlags().BoolVar(&profilesDisabled, "disable-profiles", false, "Disables profiles feature. If enabled, the client will not be able to change or edit any profile. To persist this setting, use: netbird service install --disable-profiles")
serviceCmd.PersistentFlags().BoolVar(&updateSettingsDisabled, "disable-update-settings", false, "Disables update settings feature. If enabled, the client will not be able to change or edit any settings. To persist this setting, use: netbird service install --disable-update-settings") serviceCmd.PersistentFlags().BoolVar(&updateSettingsDisabled, "disable-update-settings", false, "Disables update settings feature. If enabled, the client will not be able to change or edit any settings. To persist this setting, use: netbird service install --disable-update-settings")
serviceCmd.PersistentFlags().BoolVar(&captureEnabled, "enable-capture", false, "Enables packet capture via 'netbird debug capture'. To persist, use: netbird service install --enable-capture")
rootCmd.PersistentFlags().StringVarP(&serviceName, "service", "s", defaultServiceName, "Netbird system service name") rootCmd.PersistentFlags().StringVarP(&serviceName, "service", "s", defaultServiceName, "Netbird system service name")
serviceEnvDesc := `Sets extra environment variables for the service. ` + serviceEnvDesc := `Sets extra environment variables for the service. ` +

View File

@@ -61,7 +61,7 @@ func (p *program) Start(svc service.Service) error {
} }
} }
serverInstance := server.New(p.ctx, util.FindFirstLogPath(logFiles), configPath, profilesDisabled, updateSettingsDisabled) serverInstance := server.New(p.ctx, util.FindFirstLogPath(logFiles), configPath, profilesDisabled, updateSettingsDisabled, captureEnabled)
if err := serverInstance.Start(); err != nil { if err := serverInstance.Start(); err != nil {
log.Fatalf("failed to start daemon: %v", err) log.Fatalf("failed to start daemon: %v", err)
} }

View File

@@ -59,6 +59,10 @@ func buildServiceArguments() []string {
args = append(args, "--disable-update-settings") args = append(args, "--disable-update-settings")
} }
if captureEnabled {
args = append(args, "--enable-capture")
}
return args return args
} }

View File

@@ -28,6 +28,7 @@ type serviceParams struct {
LogFiles []string `json:"log_files,omitempty"` LogFiles []string `json:"log_files,omitempty"`
DisableProfiles bool `json:"disable_profiles,omitempty"` DisableProfiles bool `json:"disable_profiles,omitempty"`
DisableUpdateSettings bool `json:"disable_update_settings,omitempty"` DisableUpdateSettings bool `json:"disable_update_settings,omitempty"`
EnableCapture bool `json:"enable_capture,omitempty"`
ServiceEnvVars map[string]string `json:"service_env_vars,omitempty"` ServiceEnvVars map[string]string `json:"service_env_vars,omitempty"`
} }
@@ -78,6 +79,7 @@ func currentServiceParams() *serviceParams {
LogFiles: logFiles, LogFiles: logFiles,
DisableProfiles: profilesDisabled, DisableProfiles: profilesDisabled,
DisableUpdateSettings: updateSettingsDisabled, DisableUpdateSettings: updateSettingsDisabled,
EnableCapture: captureEnabled,
} }
if len(serviceEnvVars) > 0 { if len(serviceEnvVars) > 0 {
@@ -142,6 +144,10 @@ func applyServiceParams(cmd *cobra.Command, params *serviceParams) {
updateSettingsDisabled = params.DisableUpdateSettings updateSettingsDisabled = params.DisableUpdateSettings
} }
if !serviceCmd.PersistentFlags().Changed("enable-capture") {
captureEnabled = params.EnableCapture
}
applyServiceEnvParams(cmd, params) applyServiceEnvParams(cmd, params)
} }

View File

@@ -500,6 +500,7 @@ func fieldToGlobalVar(field string) string {
"LogFiles": "logFiles", "LogFiles": "logFiles",
"DisableProfiles": "profilesDisabled", "DisableProfiles": "profilesDisabled",
"DisableUpdateSettings": "updateSettingsDisabled", "DisableUpdateSettings": "updateSettingsDisabled",
"EnableCapture": "captureEnabled",
"ServiceEnvVars": "serviceEnvVars", "ServiceEnvVars": "serviceEnvVars",
} }
if v, ok := m[field]; ok { if v, ok := m[field]; ok {

View File

@@ -152,7 +152,7 @@ func startClientDaemon(
s := grpc.NewServer() s := grpc.NewServer()
server := client.New(ctx, server := client.New(ctx,
"", "", false, false) "", "", false, false, false)
if err := server.Start(); err != nil { if err := server.Start(); err != nil {
t.Fatal(err) t.Fatal(err)
} }

View File

@@ -24,6 +24,7 @@ import (
"github.com/netbirdio/netbird/client/system" "github.com/netbirdio/netbird/client/system"
"github.com/netbirdio/netbird/shared/management/domain" "github.com/netbirdio/netbird/shared/management/domain"
mgmProto "github.com/netbirdio/netbird/shared/management/proto" mgmProto "github.com/netbirdio/netbird/shared/management/proto"
"github.com/netbirdio/netbird/util/capture"
) )
var ( var (
@@ -489,6 +490,22 @@ func (c *Client) getEngine() (*internal.Engine, error) {
return engine, nil return engine, nil
} }
// SetCapture sets or clears packet capture on this client's WireGuard device.
// Pass nil to disable capture.
func (c *Client) SetCapture(sess *capture.Session) error {
engine, err := c.getEngine()
if err != nil {
return err
}
// Explicit nil check to avoid wrapping a nil *Session in the
// device.PacketCapture interface, which would appear non-nil to
// FilteredDevice and cause a nil-pointer dereference in Offer.
if sess == nil {
return engine.SetCapture(nil)
}
return engine.SetCapture(sess)
}
func (c *Client) getNet() (*wgnetstack.Net, netip.Addr, error) { func (c *Client) getNet() (*wgnetstack.Net, netip.Addr, error) {
engine, err := c.getEngine() engine, err := c.getEngine()
if err != nil { if err != nil {

View File

@@ -115,12 +115,13 @@ type Manager struct {
localipmanager *localIPManager localipmanager *localIPManager
udpTracker *conntrack.UDPTracker udpTracker *conntrack.UDPTracker
icmpTracker *conntrack.ICMPTracker icmpTracker *conntrack.ICMPTracker
tcpTracker *conntrack.TCPTracker tcpTracker *conntrack.TCPTracker
forwarder atomic.Pointer[forwarder.Forwarder] forwarder atomic.Pointer[forwarder.Forwarder]
logger *nblog.Logger pendingCapture atomic.Pointer[forwarder.PacketCapture]
flowLogger nftypes.FlowLogger logger *nblog.Logger
flowLogger nftypes.FlowLogger
blockRule firewall.Rule blockRule firewall.Rule
@@ -351,6 +352,19 @@ func (m *Manager) determineRouting() error {
return nil return nil
} }
// SetPacketCapture sets or clears packet capture on the forwarder endpoint.
// This captures outbound response packets that bypass the FilteredDevice in netstack mode.
func (m *Manager) SetPacketCapture(pc forwarder.PacketCapture) {
if pc == nil {
m.pendingCapture.Store(nil)
} else {
m.pendingCapture.Store(&pc)
}
if fwder := m.forwarder.Load(); fwder != nil {
fwder.SetCapture(pc)
}
}
// initForwarder initializes the forwarder, it disables routing on errors // initForwarder initializes the forwarder, it disables routing on errors
func (m *Manager) initForwarder() error { func (m *Manager) initForwarder() error {
if m.forwarder.Load() != nil { if m.forwarder.Load() != nil {
@@ -370,6 +384,10 @@ func (m *Manager) initForwarder() error {
return fmt.Errorf("create forwarder: %w", err) return fmt.Errorf("create forwarder: %w", err)
} }
if pc := m.pendingCapture.Load(); pc != nil {
forwarder.SetCapture(*pc)
}
m.forwarder.Store(forwarder) m.forwarder.Store(forwarder)
log.Debug("forwarder initialized") log.Debug("forwarder initialized")
@@ -614,6 +632,7 @@ func (m *Manager) resetState() {
} }
if fwder := m.forwarder.Load(); fwder != nil { if fwder := m.forwarder.Load(); fwder != nil {
fwder.SetCapture(nil)
fwder.Stop() fwder.Stop()
} }

View File

@@ -12,12 +12,19 @@ import (
nblog "github.com/netbirdio/netbird/client/firewall/uspfilter/log" nblog "github.com/netbirdio/netbird/client/firewall/uspfilter/log"
) )
// PacketCapture captures raw packets for debugging. Implementations must be
// safe for concurrent use and must not block.
type PacketCapture interface {
Offer(data []byte, outbound bool)
}
// endpoint implements stack.LinkEndpoint and handles integration with the wireguard device // endpoint implements stack.LinkEndpoint and handles integration with the wireguard device
type endpoint struct { type endpoint struct {
logger *nblog.Logger logger *nblog.Logger
dispatcher stack.NetworkDispatcher dispatcher stack.NetworkDispatcher
device *wgdevice.Device device *wgdevice.Device
mtu atomic.Uint32 mtu atomic.Uint32
capture atomic.Pointer[PacketCapture]
} }
func (e *endpoint) Attach(dispatcher stack.NetworkDispatcher) { func (e *endpoint) Attach(dispatcher stack.NetworkDispatcher) {
@@ -54,13 +61,17 @@ func (e *endpoint) WritePackets(pkts stack.PacketBufferList) (int, tcpip.Error)
continue continue
} }
// Send the packet through WireGuard pktBytes := data.AsSlice()
address := netHeader.DestinationAddress() address := netHeader.DestinationAddress()
err := e.device.CreateOutboundPacket(data.AsSlice(), address.AsSlice()) if err := e.device.CreateOutboundPacket(pktBytes, address.AsSlice()); err != nil {
if err != nil {
e.logger.Error1("CreateOutboundPacket: %v", err) e.logger.Error1("CreateOutboundPacket: %v", err)
continue continue
} }
if pc := e.capture.Load(); pc != nil {
(*pc).Offer(pktBytes, true)
}
written++ written++
} }

View File

@@ -139,6 +139,16 @@ func New(iface common.IFaceMapper, logger *nblog.Logger, flowLogger nftypes.Flow
return f, nil return f, nil
} }
// SetCapture sets or clears the packet capture on the forwarder endpoint.
// This captures outbound packets that bypass the FilteredDevice (netstack forwarding).
func (f *Forwarder) SetCapture(pc PacketCapture) {
if pc == nil {
f.endpoint.capture.Store(nil)
return
}
f.endpoint.capture.Store(&pc)
}
func (f *Forwarder) InjectIncomingPacket(payload []byte) error { func (f *Forwarder) InjectIncomingPacket(payload []byte) error {
if len(payload) < header.IPv4MinimumSize { if len(payload) < header.IPv4MinimumSize {
return fmt.Errorf("packet too small: %d bytes", len(payload)) return fmt.Errorf("packet too small: %d bytes", len(payload))

View File

@@ -270,5 +270,9 @@ func (f *Forwarder) injectICMPReply(id stack.TransportEndpointID, icmpPayload []
return 0 return 0
} }
if pc := f.endpoint.capture.Load(); pc != nil {
(*pc).Offer(fullPacket, true)
}
return len(fullPacket) return len(fullPacket)
} }

View File

@@ -3,6 +3,7 @@ package device
import ( import (
"net/netip" "net/netip"
"sync" "sync"
"sync/atomic"
"golang.zx2c4.com/wireguard/tun" "golang.zx2c4.com/wireguard/tun"
) )
@@ -28,11 +29,20 @@ type PacketFilter interface {
SetTCPPacketHook(ip netip.Addr, dPort uint16, hook func(packet []byte) bool) SetTCPPacketHook(ip netip.Addr, dPort uint16, hook func(packet []byte) bool)
} }
// PacketCapture captures raw packets for debugging. Implementations must be
// safe for concurrent use and must not block.
type PacketCapture interface {
// Offer submits a packet for capture. outbound is true for packets
// leaving the host (Read path), false for packets arriving (Write path).
Offer(data []byte, outbound bool)
}
// FilteredDevice to override Read or Write of packets // FilteredDevice to override Read or Write of packets
type FilteredDevice struct { type FilteredDevice struct {
tun.Device tun.Device
filter PacketFilter filter PacketFilter
capture atomic.Pointer[PacketCapture]
mutex sync.RWMutex mutex sync.RWMutex
closeOnce sync.Once closeOnce sync.Once
} }
@@ -63,20 +73,25 @@ func (d *FilteredDevice) Read(bufs [][]byte, sizes []int, offset int) (n int, er
if n, err = d.Device.Read(bufs, sizes, offset); err != nil { if n, err = d.Device.Read(bufs, sizes, offset); err != nil {
return 0, err return 0, err
} }
d.mutex.RLock() d.mutex.RLock()
filter := d.filter filter := d.filter
d.mutex.RUnlock() d.mutex.RUnlock()
if filter == nil { if filter != nil {
return for i := 0; i < n; i++ {
if filter.FilterOutbound(bufs[i][offset:offset+sizes[i]], sizes[i]) {
bufs = append(bufs[:i], bufs[i+1:]...)
sizes = append(sizes[:i], sizes[i+1:]...)
n--
i--
}
}
} }
for i := 0; i < n; i++ { if pc := d.capture.Load(); pc != nil {
if filter.FilterOutbound(bufs[i][offset:offset+sizes[i]], sizes[i]) { for i := 0; i < n; i++ {
bufs = append(bufs[:i], bufs[i+1:]...) (*pc).Offer(bufs[i][offset:offset+sizes[i]], true)
sizes = append(sizes[:i], sizes[i+1:]...)
n--
i--
} }
} }
@@ -85,6 +100,13 @@ func (d *FilteredDevice) Read(bufs [][]byte, sizes []int, offset int) (n int, er
// Write wraps write method with filtering feature // Write wraps write method with filtering feature
func (d *FilteredDevice) Write(bufs [][]byte, offset int) (int, error) { func (d *FilteredDevice) Write(bufs [][]byte, offset int) (int, error) {
// Capture before filtering so dropped packets are still visible in captures.
if pc := d.capture.Load(); pc != nil {
for _, buf := range bufs {
(*pc).Offer(buf[offset:], false)
}
}
d.mutex.RLock() d.mutex.RLock()
filter := d.filter filter := d.filter
d.mutex.RUnlock() d.mutex.RUnlock()
@@ -96,9 +118,10 @@ func (d *FilteredDevice) Write(bufs [][]byte, offset int) (int, error) {
filteredBufs := make([][]byte, 0, len(bufs)) filteredBufs := make([][]byte, 0, len(bufs))
dropped := 0 dropped := 0
for _, buf := range bufs { for _, buf := range bufs {
if !filter.FilterInbound(buf[offset:], len(buf)) { if filter.FilterInbound(buf[offset:], len(buf)) {
filteredBufs = append(filteredBufs, buf)
dropped++ dropped++
} else {
filteredBufs = append(filteredBufs, buf)
} }
} }
@@ -113,3 +136,14 @@ func (d *FilteredDevice) SetFilter(filter PacketFilter) {
d.filter = filter d.filter = filter
d.mutex.Unlock() d.mutex.Unlock()
} }
// SetCapture sets or clears the packet capture sink. Pass nil to disable.
// Uses atomic store so the hot path (Read/Write) is a single pointer load
// with no locking overhead when capture is off.
func (d *FilteredDevice) SetCapture(pc PacketCapture) {
if pc == nil {
d.capture.Store(nil)
return
}
d.capture.Store(&pc)
}

View File

@@ -158,7 +158,7 @@ func TestDeviceWrapperRead(t *testing.T) {
t.Errorf("unexpected error: %v", err) t.Errorf("unexpected error: %v", err)
return return
} }
if n != 0 { if n != 1 {
t.Errorf("expected n=1, got %d", n) t.Errorf("expected n=1, got %d", n)
return return
} }

View File

@@ -63,6 +63,7 @@ allocs.prof: Allocations profiling information.
threadcreate.prof: Thread creation profiling information. threadcreate.prof: Thread creation profiling information.
cpu.prof: CPU profiling information. cpu.prof: CPU profiling information.
stack_trace.txt: Complete stack traces of all goroutines at the time of bundle creation. stack_trace.txt: Complete stack traces of all goroutines at the time of bundle creation.
capture.pcap: Packet capture in pcap format. Only present when capture was running during bundle collection. Omitted from anonymized bundles because it contains raw decrypted packet data.
Anonymization Process Anonymization Process
@@ -235,6 +236,7 @@ type BundleGenerator struct {
syncResponse *mgmProto.SyncResponse syncResponse *mgmProto.SyncResponse
logPath string logPath string
cpuProfile []byte cpuProfile []byte
capturePath string
refreshStatus func() // Optional callback to refresh status before bundle generation refreshStatus func() // Optional callback to refresh status before bundle generation
clientMetrics MetricsExporter clientMetrics MetricsExporter
@@ -257,7 +259,8 @@ type GeneratorDependencies struct {
SyncResponse *mgmProto.SyncResponse SyncResponse *mgmProto.SyncResponse
LogPath string LogPath string
CPUProfile []byte CPUProfile []byte
RefreshStatus func() // Optional callback to refresh status before bundle generation CapturePath string
RefreshStatus func()
ClientMetrics MetricsExporter ClientMetrics MetricsExporter
} }
@@ -276,6 +279,7 @@ func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGen
syncResponse: deps.SyncResponse, syncResponse: deps.SyncResponse,
logPath: deps.LogPath, logPath: deps.LogPath,
cpuProfile: deps.CPUProfile, cpuProfile: deps.CPUProfile,
capturePath: deps.CapturePath,
refreshStatus: deps.RefreshStatus, refreshStatus: deps.RefreshStatus,
clientMetrics: deps.ClientMetrics, clientMetrics: deps.ClientMetrics,
@@ -345,6 +349,10 @@ func (g *BundleGenerator) createArchive() error {
log.Errorf("failed to add CPU profile to debug bundle: %v", err) log.Errorf("failed to add CPU profile to debug bundle: %v", err)
} }
if err := g.addCaptureFile(); err != nil {
log.Errorf("failed to add capture file to debug bundle: %v", err)
}
if err := g.addStackTrace(); err != nil { if err := g.addStackTrace(); err != nil {
log.Errorf("failed to add stack trace to debug bundle: %v", err) log.Errorf("failed to add stack trace to debug bundle: %v", err)
} }
@@ -675,6 +683,29 @@ func (g *BundleGenerator) addCPUProfile() error {
return nil return nil
} }
func (g *BundleGenerator) addCaptureFile() error {
if g.capturePath == "" {
return nil
}
if g.anonymize {
log.Info("skipping capture file in anonymized bundle (contains raw packet data)")
return nil
}
f, err := os.Open(g.capturePath)
if err != nil {
return fmt.Errorf("open capture file: %w", err)
}
defer f.Close()
if err := g.addFileToZip(f, "capture.pcap"); err != nil {
return fmt.Errorf("add capture file to zip: %w", err)
}
return nil
}
func (g *BundleGenerator) addStackTrace() error { func (g *BundleGenerator) addStackTrace() error {
buf := make([]byte, 5242880) // 5 MB buffer buf := make([]byte, 5242880) // 5 MB buffer
n := runtime.Stack(buf, true) n := runtime.Stack(buf, true)

View File

@@ -27,6 +27,7 @@ import (
nberrors "github.com/netbirdio/netbird/client/errors" nberrors "github.com/netbirdio/netbird/client/errors"
"github.com/netbirdio/netbird/client/firewall" "github.com/netbirdio/netbird/client/firewall"
firewallManager "github.com/netbirdio/netbird/client/firewall/manager" firewallManager "github.com/netbirdio/netbird/client/firewall/manager"
"github.com/netbirdio/netbird/client/firewall/uspfilter/forwarder"
"github.com/netbirdio/netbird/client/iface" "github.com/netbirdio/netbird/client/iface"
"github.com/netbirdio/netbird/client/iface/device" "github.com/netbirdio/netbird/client/iface/device"
nbnetstack "github.com/netbirdio/netbird/client/iface/netstack" nbnetstack "github.com/netbirdio/netbird/client/iface/netstack"
@@ -67,6 +68,7 @@ import (
signal "github.com/netbirdio/netbird/shared/signal/client" signal "github.com/netbirdio/netbird/shared/signal/client"
sProto "github.com/netbirdio/netbird/shared/signal/proto" sProto "github.com/netbirdio/netbird/shared/signal/proto"
"github.com/netbirdio/netbird/util" "github.com/netbirdio/netbird/util"
"github.com/netbirdio/netbird/util/capture"
) )
// PeerConnectionTimeoutMax is a timeout of an initial connection attempt to a remote peer. // PeerConnectionTimeoutMax is a timeout of an initial connection attempt to a remote peer.
@@ -216,6 +218,8 @@ type Engine struct {
portForwardManager *portforward.Manager portForwardManager *portforward.Manager
srWatcher *guard.SRWatcher srWatcher *guard.SRWatcher
afpacketCapture *capture.AFPacketCapture
// Sync response persistence (protected by syncRespMux) // Sync response persistence (protected by syncRespMux)
syncRespMux sync.RWMutex syncRespMux sync.RWMutex
persistSyncResponse bool persistSyncResponse bool
@@ -1693,6 +1697,11 @@ func (e *Engine) parseNATExternalIPMappings() []string {
} }
func (e *Engine) close() { func (e *Engine) close() {
if e.afpacketCapture != nil {
e.afpacketCapture.Stop()
e.afpacketCapture = nil
}
log.Debugf("removing Netbird interface %s", e.config.WgIfaceName) log.Debugf("removing Netbird interface %s", e.config.WgIfaceName)
if e.wgInterface != nil { if e.wgInterface != nil {
@@ -2158,6 +2167,62 @@ func (e *Engine) Address() (netip.Addr, error) {
return e.wgInterface.Address().IP, nil return e.wgInterface.Address().IP, nil
} }
// SetCapture sets or clears packet capture on the WireGuard device.
// On userspace WireGuard, it taps the FilteredDevice directly.
// On kernel WireGuard (Linux), it falls back to AF_PACKET raw socket capture.
// Pass nil to disable capture.
func (e *Engine) SetCapture(pc device.PacketCapture) error {
e.syncMsgMux.Lock()
defer e.syncMsgMux.Unlock()
intf := e.wgInterface
if intf == nil {
return errors.New("wireguard interface not initialized")
}
if e.afpacketCapture != nil {
e.afpacketCapture.Stop()
e.afpacketCapture = nil
}
dev := intf.GetDevice()
if dev != nil {
dev.SetCapture(pc)
e.setForwarderCapture(pc)
return nil
}
// Kernel mode: no FilteredDevice. Use AF_PACKET on Linux.
if pc == nil {
return nil
}
sess, ok := pc.(*capture.Session)
if !ok {
return errors.New("filtered device not available and AF_PACKET requires *capture.Session")
}
afc := capture.NewAFPacketCapture(intf.Name(), sess)
if err := afc.Start(); err != nil {
return fmt.Errorf("start AF_PACKET capture on %s: %w", intf.Name(), err)
}
e.afpacketCapture = afc
return nil
}
// setForwarderCapture propagates capture to the USP filter's forwarder endpoint.
// This captures outbound response packets that bypass the FilteredDevice in netstack mode.
func (e *Engine) setForwarderCapture(pc device.PacketCapture) {
if e.firewall == nil {
return
}
type forwarderCapturer interface {
SetPacketCapture(pc forwarder.PacketCapture)
}
if fc, ok := e.firewall.(forwarderCapturer); ok {
fc.SetPacketCapture(pc)
}
}
func (e *Engine) updateForwardRules(rules []*mgmProto.ForwardingRule) ([]firewallManager.ForwardRule, error) { func (e *Engine) updateForwardRules(rules []*mgmProto.ForwardingRule) ([]firewallManager.ForwardRule, error) {
if e.firewall == nil { if e.firewall == nil {
log.Warn("firewall is disabled, not updating forwarding rules") log.Warn("firewall is disabled, not updating forwarding rules")

View File

@@ -5969,6 +5969,288 @@ func (x *ExposeServiceReady) GetPortAutoAssigned() bool {
return false return false
} }
type StartCaptureRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
TextOutput bool `protobuf:"varint,1,opt,name=text_output,json=textOutput,proto3" json:"text_output,omitempty"`
SnapLen uint32 `protobuf:"varint,2,opt,name=snap_len,json=snapLen,proto3" json:"snap_len,omitempty"`
Duration *durationpb.Duration `protobuf:"bytes,3,opt,name=duration,proto3" json:"duration,omitempty"`
FilterExpr string `protobuf:"bytes,4,opt,name=filter_expr,json=filterExpr,proto3" json:"filter_expr,omitempty"`
Verbose bool `protobuf:"varint,5,opt,name=verbose,proto3" json:"verbose,omitempty"`
Ascii bool `protobuf:"varint,6,opt,name=ascii,proto3" json:"ascii,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *StartCaptureRequest) Reset() {
*x = StartCaptureRequest{}
mi := &file_daemon_proto_msgTypes[90]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *StartCaptureRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*StartCaptureRequest) ProtoMessage() {}
func (x *StartCaptureRequest) ProtoReflect() protoreflect.Message {
mi := &file_daemon_proto_msgTypes[90]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use StartCaptureRequest.ProtoReflect.Descriptor instead.
func (*StartCaptureRequest) Descriptor() ([]byte, []int) {
return file_daemon_proto_rawDescGZIP(), []int{90}
}
func (x *StartCaptureRequest) GetTextOutput() bool {
if x != nil {
return x.TextOutput
}
return false
}
func (x *StartCaptureRequest) GetSnapLen() uint32 {
if x != nil {
return x.SnapLen
}
return 0
}
func (x *StartCaptureRequest) GetDuration() *durationpb.Duration {
if x != nil {
return x.Duration
}
return nil
}
func (x *StartCaptureRequest) GetFilterExpr() string {
if x != nil {
return x.FilterExpr
}
return ""
}
func (x *StartCaptureRequest) GetVerbose() bool {
if x != nil {
return x.Verbose
}
return false
}
func (x *StartCaptureRequest) GetAscii() bool {
if x != nil {
return x.Ascii
}
return false
}
type CapturePacket struct {
state protoimpl.MessageState `protogen:"open.v1"`
Data []byte `protobuf:"bytes,1,opt,name=data,proto3" json:"data,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *CapturePacket) Reset() {
*x = CapturePacket{}
mi := &file_daemon_proto_msgTypes[91]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *CapturePacket) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*CapturePacket) ProtoMessage() {}
func (x *CapturePacket) ProtoReflect() protoreflect.Message {
mi := &file_daemon_proto_msgTypes[91]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use CapturePacket.ProtoReflect.Descriptor instead.
func (*CapturePacket) Descriptor() ([]byte, []int) {
return file_daemon_proto_rawDescGZIP(), []int{91}
}
func (x *CapturePacket) GetData() []byte {
if x != nil {
return x.Data
}
return nil
}
type StartBundleCaptureRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
// timeout auto-stops the capture after this duration.
// Clamped to a server-side maximum (10 minutes). Zero or unset defaults to the maximum.
Timeout *durationpb.Duration `protobuf:"bytes,1,opt,name=timeout,proto3" json:"timeout,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *StartBundleCaptureRequest) Reset() {
*x = StartBundleCaptureRequest{}
mi := &file_daemon_proto_msgTypes[92]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *StartBundleCaptureRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*StartBundleCaptureRequest) ProtoMessage() {}
func (x *StartBundleCaptureRequest) ProtoReflect() protoreflect.Message {
mi := &file_daemon_proto_msgTypes[92]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use StartBundleCaptureRequest.ProtoReflect.Descriptor instead.
func (*StartBundleCaptureRequest) Descriptor() ([]byte, []int) {
return file_daemon_proto_rawDescGZIP(), []int{92}
}
func (x *StartBundleCaptureRequest) GetTimeout() *durationpb.Duration {
if x != nil {
return x.Timeout
}
return nil
}
type StartBundleCaptureResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *StartBundleCaptureResponse) Reset() {
*x = StartBundleCaptureResponse{}
mi := &file_daemon_proto_msgTypes[93]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *StartBundleCaptureResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*StartBundleCaptureResponse) ProtoMessage() {}
func (x *StartBundleCaptureResponse) ProtoReflect() protoreflect.Message {
mi := &file_daemon_proto_msgTypes[93]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use StartBundleCaptureResponse.ProtoReflect.Descriptor instead.
func (*StartBundleCaptureResponse) Descriptor() ([]byte, []int) {
return file_daemon_proto_rawDescGZIP(), []int{93}
}
type StopBundleCaptureRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *StopBundleCaptureRequest) Reset() {
*x = StopBundleCaptureRequest{}
mi := &file_daemon_proto_msgTypes[94]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *StopBundleCaptureRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*StopBundleCaptureRequest) ProtoMessage() {}
func (x *StopBundleCaptureRequest) ProtoReflect() protoreflect.Message {
mi := &file_daemon_proto_msgTypes[94]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use StopBundleCaptureRequest.ProtoReflect.Descriptor instead.
func (*StopBundleCaptureRequest) Descriptor() ([]byte, []int) {
return file_daemon_proto_rawDescGZIP(), []int{94}
}
type StopBundleCaptureResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *StopBundleCaptureResponse) Reset() {
*x = StopBundleCaptureResponse{}
mi := &file_daemon_proto_msgTypes[95]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *StopBundleCaptureResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*StopBundleCaptureResponse) ProtoMessage() {}
func (x *StopBundleCaptureResponse) ProtoReflect() protoreflect.Message {
mi := &file_daemon_proto_msgTypes[95]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use StopBundleCaptureResponse.ProtoReflect.Descriptor instead.
func (*StopBundleCaptureResponse) Descriptor() ([]byte, []int) {
return file_daemon_proto_rawDescGZIP(), []int{95}
}
type PortInfo_Range struct { type PortInfo_Range struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Start uint32 `protobuf:"varint,1,opt,name=start,proto3" json:"start,omitempty"` Start uint32 `protobuf:"varint,1,opt,name=start,proto3" json:"start,omitempty"`
@@ -5979,7 +6261,7 @@ type PortInfo_Range struct {
func (x *PortInfo_Range) Reset() { func (x *PortInfo_Range) Reset() {
*x = PortInfo_Range{} *x = PortInfo_Range{}
mi := &file_daemon_proto_msgTypes[91] mi := &file_daemon_proto_msgTypes[97]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@@ -5991,7 +6273,7 @@ func (x *PortInfo_Range) String() string {
func (*PortInfo_Range) ProtoMessage() {} func (*PortInfo_Range) ProtoMessage() {}
func (x *PortInfo_Range) ProtoReflect() protoreflect.Message { func (x *PortInfo_Range) ProtoReflect() protoreflect.Message {
mi := &file_daemon_proto_msgTypes[91] mi := &file_daemon_proto_msgTypes[97]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@@ -6539,7 +6821,23 @@ const file_daemon_proto_rawDesc = "" +
"\vservice_url\x18\x02 \x01(\tR\n" + "\vservice_url\x18\x02 \x01(\tR\n" +
"serviceUrl\x12\x16\n" + "serviceUrl\x12\x16\n" +
"\x06domain\x18\x03 \x01(\tR\x06domain\x12,\n" + "\x06domain\x18\x03 \x01(\tR\x06domain\x12,\n" +
"\x12port_auto_assigned\x18\x04 \x01(\bR\x10portAutoAssigned*b\n" + "\x12port_auto_assigned\x18\x04 \x01(\bR\x10portAutoAssigned\"\xd9\x01\n" +
"\x13StartCaptureRequest\x12\x1f\n" +
"\vtext_output\x18\x01 \x01(\bR\n" +
"textOutput\x12\x19\n" +
"\bsnap_len\x18\x02 \x01(\rR\asnapLen\x125\n" +
"\bduration\x18\x03 \x01(\v2\x19.google.protobuf.DurationR\bduration\x12\x1f\n" +
"\vfilter_expr\x18\x04 \x01(\tR\n" +
"filterExpr\x12\x18\n" +
"\averbose\x18\x05 \x01(\bR\averbose\x12\x14\n" +
"\x05ascii\x18\x06 \x01(\bR\x05ascii\"#\n" +
"\rCapturePacket\x12\x12\n" +
"\x04data\x18\x01 \x01(\fR\x04data\"P\n" +
"\x19StartBundleCaptureRequest\x123\n" +
"\atimeout\x18\x01 \x01(\v2\x19.google.protobuf.DurationR\atimeout\"\x1c\n" +
"\x1aStartBundleCaptureResponse\"\x1a\n" +
"\x18StopBundleCaptureRequest\"\x1b\n" +
"\x19StopBundleCaptureResponse*b\n" +
"\bLogLevel\x12\v\n" + "\bLogLevel\x12\v\n" +
"\aUNKNOWN\x10\x00\x12\t\n" + "\aUNKNOWN\x10\x00\x12\t\n" +
"\x05PANIC\x10\x01\x12\t\n" + "\x05PANIC\x10\x01\x12\t\n" +
@@ -6557,7 +6855,7 @@ const file_daemon_proto_rawDesc = "" +
"\n" + "\n" +
"EXPOSE_UDP\x10\x03\x12\x0e\n" + "EXPOSE_UDP\x10\x03\x12\x0e\n" +
"\n" + "\n" +
"EXPOSE_TLS\x10\x042\xfc\x15\n" + "EXPOSE_TLS\x10\x042\xff\x17\n" +
"\rDaemonService\x126\n" + "\rDaemonService\x126\n" +
"\x05Login\x12\x14.daemon.LoginRequest\x1a\x15.daemon.LoginResponse\"\x00\x12K\n" + "\x05Login\x12\x14.daemon.LoginRequest\x1a\x15.daemon.LoginResponse\"\x00\x12K\n" +
"\fWaitSSOLogin\x12\x1b.daemon.WaitSSOLoginRequest\x1a\x1c.daemon.WaitSSOLoginResponse\"\x00\x12-\n" + "\fWaitSSOLogin\x12\x1b.daemon.WaitSSOLoginRequest\x1a\x1c.daemon.WaitSSOLoginResponse\"\x00\x12-\n" +
@@ -6578,7 +6876,10 @@ const file_daemon_proto_rawDesc = "" +
"CleanState\x12\x19.daemon.CleanStateRequest\x1a\x1a.daemon.CleanStateResponse\"\x00\x12H\n" + "CleanState\x12\x19.daemon.CleanStateRequest\x1a\x1a.daemon.CleanStateResponse\"\x00\x12H\n" +
"\vDeleteState\x12\x1a.daemon.DeleteStateRequest\x1a\x1b.daemon.DeleteStateResponse\"\x00\x12u\n" + "\vDeleteState\x12\x1a.daemon.DeleteStateRequest\x1a\x1b.daemon.DeleteStateResponse\"\x00\x12u\n" +
"\x1aSetSyncResponsePersistence\x12).daemon.SetSyncResponsePersistenceRequest\x1a*.daemon.SetSyncResponsePersistenceResponse\"\x00\x12H\n" + "\x1aSetSyncResponsePersistence\x12).daemon.SetSyncResponsePersistenceRequest\x1a*.daemon.SetSyncResponsePersistenceResponse\"\x00\x12H\n" +
"\vTracePacket\x12\x1a.daemon.TracePacketRequest\x1a\x1b.daemon.TracePacketResponse\"\x00\x12D\n" + "\vTracePacket\x12\x1a.daemon.TracePacketRequest\x1a\x1b.daemon.TracePacketResponse\"\x00\x12F\n" +
"\fStartCapture\x12\x1b.daemon.StartCaptureRequest\x1a\x15.daemon.CapturePacket\"\x000\x01\x12]\n" +
"\x12StartBundleCapture\x12!.daemon.StartBundleCaptureRequest\x1a\".daemon.StartBundleCaptureResponse\"\x00\x12Z\n" +
"\x11StopBundleCapture\x12 .daemon.StopBundleCaptureRequest\x1a!.daemon.StopBundleCaptureResponse\"\x00\x12D\n" +
"\x0fSubscribeEvents\x12\x18.daemon.SubscribeRequest\x1a\x13.daemon.SystemEvent\"\x000\x01\x12B\n" + "\x0fSubscribeEvents\x12\x18.daemon.SubscribeRequest\x1a\x13.daemon.SystemEvent\"\x000\x01\x12B\n" +
"\tGetEvents\x12\x18.daemon.GetEventsRequest\x1a\x19.daemon.GetEventsResponse\"\x00\x12N\n" + "\tGetEvents\x12\x18.daemon.GetEventsRequest\x1a\x19.daemon.GetEventsResponse\"\x00\x12N\n" +
"\rSwitchProfile\x12\x1c.daemon.SwitchProfileRequest\x1a\x1d.daemon.SwitchProfileResponse\"\x00\x12B\n" + "\rSwitchProfile\x12\x1c.daemon.SwitchProfileRequest\x1a\x1d.daemon.SwitchProfileResponse\"\x00\x12B\n" +
@@ -6613,7 +6914,7 @@ func file_daemon_proto_rawDescGZIP() []byte {
} }
var file_daemon_proto_enumTypes = make([]protoimpl.EnumInfo, 5) var file_daemon_proto_enumTypes = make([]protoimpl.EnumInfo, 5)
var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 93) var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 99)
var file_daemon_proto_goTypes = []any{ var file_daemon_proto_goTypes = []any{
(LogLevel)(0), // 0: daemon.LogLevel (LogLevel)(0), // 0: daemon.LogLevel
(ExposeProtocol)(0), // 1: daemon.ExposeProtocol (ExposeProtocol)(0), // 1: daemon.ExposeProtocol
@@ -6710,128 +7011,142 @@ var file_daemon_proto_goTypes = []any{
(*ExposeServiceRequest)(nil), // 92: daemon.ExposeServiceRequest (*ExposeServiceRequest)(nil), // 92: daemon.ExposeServiceRequest
(*ExposeServiceEvent)(nil), // 93: daemon.ExposeServiceEvent (*ExposeServiceEvent)(nil), // 93: daemon.ExposeServiceEvent
(*ExposeServiceReady)(nil), // 94: daemon.ExposeServiceReady (*ExposeServiceReady)(nil), // 94: daemon.ExposeServiceReady
nil, // 95: daemon.Network.ResolvedIPsEntry (*StartCaptureRequest)(nil), // 95: daemon.StartCaptureRequest
(*PortInfo_Range)(nil), // 96: daemon.PortInfo.Range (*CapturePacket)(nil), // 96: daemon.CapturePacket
nil, // 97: daemon.SystemEvent.MetadataEntry (*StartBundleCaptureRequest)(nil), // 97: daemon.StartBundleCaptureRequest
(*durationpb.Duration)(nil), // 98: google.protobuf.Duration (*StartBundleCaptureResponse)(nil), // 98: daemon.StartBundleCaptureResponse
(*timestamppb.Timestamp)(nil), // 99: google.protobuf.Timestamp (*StopBundleCaptureRequest)(nil), // 99: daemon.StopBundleCaptureRequest
(*StopBundleCaptureResponse)(nil), // 100: daemon.StopBundleCaptureResponse
nil, // 101: daemon.Network.ResolvedIPsEntry
(*PortInfo_Range)(nil), // 102: daemon.PortInfo.Range
nil, // 103: daemon.SystemEvent.MetadataEntry
(*durationpb.Duration)(nil), // 104: google.protobuf.Duration
(*timestamppb.Timestamp)(nil), // 105: google.protobuf.Timestamp
} }
var file_daemon_proto_depIdxs = []int32{ var file_daemon_proto_depIdxs = []int32{
2, // 0: daemon.OSLifecycleRequest.type:type_name -> daemon.OSLifecycleRequest.CycleType 2, // 0: daemon.OSLifecycleRequest.type:type_name -> daemon.OSLifecycleRequest.CycleType
98, // 1: daemon.LoginRequest.dnsRouteInterval:type_name -> google.protobuf.Duration 104, // 1: daemon.LoginRequest.dnsRouteInterval:type_name -> google.protobuf.Duration
28, // 2: daemon.StatusResponse.fullStatus:type_name -> daemon.FullStatus 28, // 2: daemon.StatusResponse.fullStatus:type_name -> daemon.FullStatus
99, // 3: daemon.PeerState.connStatusUpdate:type_name -> google.protobuf.Timestamp 105, // 3: daemon.PeerState.connStatusUpdate:type_name -> google.protobuf.Timestamp
99, // 4: daemon.PeerState.lastWireguardHandshake:type_name -> google.protobuf.Timestamp 105, // 4: daemon.PeerState.lastWireguardHandshake:type_name -> google.protobuf.Timestamp
98, // 5: daemon.PeerState.latency:type_name -> google.protobuf.Duration 104, // 5: daemon.PeerState.latency:type_name -> google.protobuf.Duration
26, // 6: daemon.SSHServerState.sessions:type_name -> daemon.SSHSessionInfo 26, // 6: daemon.SSHServerState.sessions:type_name -> daemon.SSHSessionInfo
23, // 7: daemon.FullStatus.managementState:type_name -> daemon.ManagementState 23, // 7: daemon.FullStatus.managementState:type_name -> daemon.ManagementState
22, // 8: daemon.FullStatus.signalState:type_name -> daemon.SignalState 22, // 8: daemon.FullStatus.signalState:type_name -> daemon.SignalState
21, // 9: daemon.FullStatus.localPeerState:type_name -> daemon.LocalPeerState 21, // 9: daemon.FullStatus.localPeerState:type_name -> daemon.LocalPeerState
20, // 10: daemon.FullStatus.peers:type_name -> daemon.PeerState 20, // 10: daemon.FullStatus.peers:type_name -> daemon.PeerState
24, // 11: daemon.FullStatus.relays:type_name -> daemon.RelayState 24, // 11: daemon.FullStatus.relays:type_name -> daemon.RelayState
25, // 12: daemon.FullStatus.dns_servers:type_name -> daemon.NSGroupState 25, // 12: daemon.FullStatus.dns_servers:type_name -> daemon.NSGroupState
58, // 13: daemon.FullStatus.events:type_name -> daemon.SystemEvent 58, // 13: daemon.FullStatus.events:type_name -> daemon.SystemEvent
27, // 14: daemon.FullStatus.sshServerState:type_name -> daemon.SSHServerState 27, // 14: daemon.FullStatus.sshServerState:type_name -> daemon.SSHServerState
34, // 15: daemon.ListNetworksResponse.routes:type_name -> daemon.Network 34, // 15: daemon.ListNetworksResponse.routes:type_name -> daemon.Network
95, // 16: daemon.Network.resolvedIPs:type_name -> daemon.Network.ResolvedIPsEntry 101, // 16: daemon.Network.resolvedIPs:type_name -> daemon.Network.ResolvedIPsEntry
96, // 17: daemon.PortInfo.range:type_name -> daemon.PortInfo.Range 102, // 17: daemon.PortInfo.range:type_name -> daemon.PortInfo.Range
35, // 18: daemon.ForwardingRule.destinationPort:type_name -> daemon.PortInfo 35, // 18: daemon.ForwardingRule.destinationPort:type_name -> daemon.PortInfo
35, // 19: daemon.ForwardingRule.translatedPort:type_name -> daemon.PortInfo 35, // 19: daemon.ForwardingRule.translatedPort:type_name -> daemon.PortInfo
36, // 20: daemon.ForwardingRulesResponse.rules:type_name -> daemon.ForwardingRule 36, // 20: daemon.ForwardingRulesResponse.rules:type_name -> daemon.ForwardingRule
0, // 21: daemon.GetLogLevelResponse.level:type_name -> daemon.LogLevel 0, // 21: daemon.GetLogLevelResponse.level:type_name -> daemon.LogLevel
0, // 22: daemon.SetLogLevelRequest.level:type_name -> daemon.LogLevel 0, // 22: daemon.SetLogLevelRequest.level:type_name -> daemon.LogLevel
44, // 23: daemon.ListStatesResponse.states:type_name -> daemon.State 44, // 23: daemon.ListStatesResponse.states:type_name -> daemon.State
53, // 24: daemon.TracePacketRequest.tcp_flags:type_name -> daemon.TCPFlags 53, // 24: daemon.TracePacketRequest.tcp_flags:type_name -> daemon.TCPFlags
55, // 25: daemon.TracePacketResponse.stages:type_name -> daemon.TraceStage 55, // 25: daemon.TracePacketResponse.stages:type_name -> daemon.TraceStage
3, // 26: daemon.SystemEvent.severity:type_name -> daemon.SystemEvent.Severity 3, // 26: daemon.SystemEvent.severity:type_name -> daemon.SystemEvent.Severity
4, // 27: daemon.SystemEvent.category:type_name -> daemon.SystemEvent.Category 4, // 27: daemon.SystemEvent.category:type_name -> daemon.SystemEvent.Category
99, // 28: daemon.SystemEvent.timestamp:type_name -> google.protobuf.Timestamp 105, // 28: daemon.SystemEvent.timestamp:type_name -> google.protobuf.Timestamp
97, // 29: daemon.SystemEvent.metadata:type_name -> daemon.SystemEvent.MetadataEntry 103, // 29: daemon.SystemEvent.metadata:type_name -> daemon.SystemEvent.MetadataEntry
58, // 30: daemon.GetEventsResponse.events:type_name -> daemon.SystemEvent 58, // 30: daemon.GetEventsResponse.events:type_name -> daemon.SystemEvent
98, // 31: daemon.SetConfigRequest.dnsRouteInterval:type_name -> google.protobuf.Duration 104, // 31: daemon.SetConfigRequest.dnsRouteInterval:type_name -> google.protobuf.Duration
71, // 32: daemon.ListProfilesResponse.profiles:type_name -> daemon.Profile 71, // 32: daemon.ListProfilesResponse.profiles:type_name -> daemon.Profile
1, // 33: daemon.ExposeServiceRequest.protocol:type_name -> daemon.ExposeProtocol 1, // 33: daemon.ExposeServiceRequest.protocol:type_name -> daemon.ExposeProtocol
94, // 34: daemon.ExposeServiceEvent.ready:type_name -> daemon.ExposeServiceReady 94, // 34: daemon.ExposeServiceEvent.ready:type_name -> daemon.ExposeServiceReady
33, // 35: daemon.Network.ResolvedIPsEntry.value:type_name -> daemon.IPList 104, // 35: daemon.StartCaptureRequest.duration:type_name -> google.protobuf.Duration
8, // 36: daemon.DaemonService.Login:input_type -> daemon.LoginRequest 104, // 36: daemon.StartBundleCaptureRequest.timeout:type_name -> google.protobuf.Duration
10, // 37: daemon.DaemonService.WaitSSOLogin:input_type -> daemon.WaitSSOLoginRequest 33, // 37: daemon.Network.ResolvedIPsEntry.value:type_name -> daemon.IPList
12, // 38: daemon.DaemonService.Up:input_type -> daemon.UpRequest 8, // 38: daemon.DaemonService.Login:input_type -> daemon.LoginRequest
14, // 39: daemon.DaemonService.Status:input_type -> daemon.StatusRequest 10, // 39: daemon.DaemonService.WaitSSOLogin:input_type -> daemon.WaitSSOLoginRequest
16, // 40: daemon.DaemonService.Down:input_type -> daemon.DownRequest 12, // 40: daemon.DaemonService.Up:input_type -> daemon.UpRequest
18, // 41: daemon.DaemonService.GetConfig:input_type -> daemon.GetConfigRequest 14, // 41: daemon.DaemonService.Status:input_type -> daemon.StatusRequest
29, // 42: daemon.DaemonService.ListNetworks:input_type -> daemon.ListNetworksRequest 16, // 42: daemon.DaemonService.Down:input_type -> daemon.DownRequest
31, // 43: daemon.DaemonService.SelectNetworks:input_type -> daemon.SelectNetworksRequest 18, // 43: daemon.DaemonService.GetConfig:input_type -> daemon.GetConfigRequest
31, // 44: daemon.DaemonService.DeselectNetworks:input_type -> daemon.SelectNetworksRequest 29, // 44: daemon.DaemonService.ListNetworks:input_type -> daemon.ListNetworksRequest
5, // 45: daemon.DaemonService.ForwardingRules:input_type -> daemon.EmptyRequest 31, // 45: daemon.DaemonService.SelectNetworks:input_type -> daemon.SelectNetworksRequest
38, // 46: daemon.DaemonService.DebugBundle:input_type -> daemon.DebugBundleRequest 31, // 46: daemon.DaemonService.DeselectNetworks:input_type -> daemon.SelectNetworksRequest
40, // 47: daemon.DaemonService.GetLogLevel:input_type -> daemon.GetLogLevelRequest 5, // 47: daemon.DaemonService.ForwardingRules:input_type -> daemon.EmptyRequest
42, // 48: daemon.DaemonService.SetLogLevel:input_type -> daemon.SetLogLevelRequest 38, // 48: daemon.DaemonService.DebugBundle:input_type -> daemon.DebugBundleRequest
45, // 49: daemon.DaemonService.ListStates:input_type -> daemon.ListStatesRequest 40, // 49: daemon.DaemonService.GetLogLevel:input_type -> daemon.GetLogLevelRequest
47, // 50: daemon.DaemonService.CleanState:input_type -> daemon.CleanStateRequest 42, // 50: daemon.DaemonService.SetLogLevel:input_type -> daemon.SetLogLevelRequest
49, // 51: daemon.DaemonService.DeleteState:input_type -> daemon.DeleteStateRequest 45, // 51: daemon.DaemonService.ListStates:input_type -> daemon.ListStatesRequest
51, // 52: daemon.DaemonService.SetSyncResponsePersistence:input_type -> daemon.SetSyncResponsePersistenceRequest 47, // 52: daemon.DaemonService.CleanState:input_type -> daemon.CleanStateRequest
54, // 53: daemon.DaemonService.TracePacket:input_type -> daemon.TracePacketRequest 49, // 53: daemon.DaemonService.DeleteState:input_type -> daemon.DeleteStateRequest
57, // 54: daemon.DaemonService.SubscribeEvents:input_type -> daemon.SubscribeRequest 51, // 54: daemon.DaemonService.SetSyncResponsePersistence:input_type -> daemon.SetSyncResponsePersistenceRequest
59, // 55: daemon.DaemonService.GetEvents:input_type -> daemon.GetEventsRequest 54, // 55: daemon.DaemonService.TracePacket:input_type -> daemon.TracePacketRequest
61, // 56: daemon.DaemonService.SwitchProfile:input_type -> daemon.SwitchProfileRequest 95, // 56: daemon.DaemonService.StartCapture:input_type -> daemon.StartCaptureRequest
63, // 57: daemon.DaemonService.SetConfig:input_type -> daemon.SetConfigRequest 97, // 57: daemon.DaemonService.StartBundleCapture:input_type -> daemon.StartBundleCaptureRequest
65, // 58: daemon.DaemonService.AddProfile:input_type -> daemon.AddProfileRequest 99, // 58: daemon.DaemonService.StopBundleCapture:input_type -> daemon.StopBundleCaptureRequest
67, // 59: daemon.DaemonService.RemoveProfile:input_type -> daemon.RemoveProfileRequest 57, // 59: daemon.DaemonService.SubscribeEvents:input_type -> daemon.SubscribeRequest
69, // 60: daemon.DaemonService.ListProfiles:input_type -> daemon.ListProfilesRequest 59, // 60: daemon.DaemonService.GetEvents:input_type -> daemon.GetEventsRequest
72, // 61: daemon.DaemonService.GetActiveProfile:input_type -> daemon.GetActiveProfileRequest 61, // 61: daemon.DaemonService.SwitchProfile:input_type -> daemon.SwitchProfileRequest
74, // 62: daemon.DaemonService.Logout:input_type -> daemon.LogoutRequest 63, // 62: daemon.DaemonService.SetConfig:input_type -> daemon.SetConfigRequest
76, // 63: daemon.DaemonService.GetFeatures:input_type -> daemon.GetFeaturesRequest 65, // 63: daemon.DaemonService.AddProfile:input_type -> daemon.AddProfileRequest
78, // 64: daemon.DaemonService.TriggerUpdate:input_type -> daemon.TriggerUpdateRequest 67, // 64: daemon.DaemonService.RemoveProfile:input_type -> daemon.RemoveProfileRequest
80, // 65: daemon.DaemonService.GetPeerSSHHostKey:input_type -> daemon.GetPeerSSHHostKeyRequest 69, // 65: daemon.DaemonService.ListProfiles:input_type -> daemon.ListProfilesRequest
82, // 66: daemon.DaemonService.RequestJWTAuth:input_type -> daemon.RequestJWTAuthRequest 72, // 66: daemon.DaemonService.GetActiveProfile:input_type -> daemon.GetActiveProfileRequest
84, // 67: daemon.DaemonService.WaitJWTToken:input_type -> daemon.WaitJWTTokenRequest 74, // 67: daemon.DaemonService.Logout:input_type -> daemon.LogoutRequest
86, // 68: daemon.DaemonService.StartCPUProfile:input_type -> daemon.StartCPUProfileRequest 76, // 68: daemon.DaemonService.GetFeatures:input_type -> daemon.GetFeaturesRequest
88, // 69: daemon.DaemonService.StopCPUProfile:input_type -> daemon.StopCPUProfileRequest 78, // 69: daemon.DaemonService.TriggerUpdate:input_type -> daemon.TriggerUpdateRequest
6, // 70: daemon.DaemonService.NotifyOSLifecycle:input_type -> daemon.OSLifecycleRequest 80, // 70: daemon.DaemonService.GetPeerSSHHostKey:input_type -> daemon.GetPeerSSHHostKeyRequest
90, // 71: daemon.DaemonService.GetInstallerResult:input_type -> daemon.InstallerResultRequest 82, // 71: daemon.DaemonService.RequestJWTAuth:input_type -> daemon.RequestJWTAuthRequest
92, // 72: daemon.DaemonService.ExposeService:input_type -> daemon.ExposeServiceRequest 84, // 72: daemon.DaemonService.WaitJWTToken:input_type -> daemon.WaitJWTTokenRequest
9, // 73: daemon.DaemonService.Login:output_type -> daemon.LoginResponse 86, // 73: daemon.DaemonService.StartCPUProfile:input_type -> daemon.StartCPUProfileRequest
11, // 74: daemon.DaemonService.WaitSSOLogin:output_type -> daemon.WaitSSOLoginResponse 88, // 74: daemon.DaemonService.StopCPUProfile:input_type -> daemon.StopCPUProfileRequest
13, // 75: daemon.DaemonService.Up:output_type -> daemon.UpResponse 6, // 75: daemon.DaemonService.NotifyOSLifecycle:input_type -> daemon.OSLifecycleRequest
15, // 76: daemon.DaemonService.Status:output_type -> daemon.StatusResponse 90, // 76: daemon.DaemonService.GetInstallerResult:input_type -> daemon.InstallerResultRequest
17, // 77: daemon.DaemonService.Down:output_type -> daemon.DownResponse 92, // 77: daemon.DaemonService.ExposeService:input_type -> daemon.ExposeServiceRequest
19, // 78: daemon.DaemonService.GetConfig:output_type -> daemon.GetConfigResponse 9, // 78: daemon.DaemonService.Login:output_type -> daemon.LoginResponse
30, // 79: daemon.DaemonService.ListNetworks:output_type -> daemon.ListNetworksResponse 11, // 79: daemon.DaemonService.WaitSSOLogin:output_type -> daemon.WaitSSOLoginResponse
32, // 80: daemon.DaemonService.SelectNetworks:output_type -> daemon.SelectNetworksResponse 13, // 80: daemon.DaemonService.Up:output_type -> daemon.UpResponse
32, // 81: daemon.DaemonService.DeselectNetworks:output_type -> daemon.SelectNetworksResponse 15, // 81: daemon.DaemonService.Status:output_type -> daemon.StatusResponse
37, // 82: daemon.DaemonService.ForwardingRules:output_type -> daemon.ForwardingRulesResponse 17, // 82: daemon.DaemonService.Down:output_type -> daemon.DownResponse
39, // 83: daemon.DaemonService.DebugBundle:output_type -> daemon.DebugBundleResponse 19, // 83: daemon.DaemonService.GetConfig:output_type -> daemon.GetConfigResponse
41, // 84: daemon.DaemonService.GetLogLevel:output_type -> daemon.GetLogLevelResponse 30, // 84: daemon.DaemonService.ListNetworks:output_type -> daemon.ListNetworksResponse
43, // 85: daemon.DaemonService.SetLogLevel:output_type -> daemon.SetLogLevelResponse 32, // 85: daemon.DaemonService.SelectNetworks:output_type -> daemon.SelectNetworksResponse
46, // 86: daemon.DaemonService.ListStates:output_type -> daemon.ListStatesResponse 32, // 86: daemon.DaemonService.DeselectNetworks:output_type -> daemon.SelectNetworksResponse
48, // 87: daemon.DaemonService.CleanState:output_type -> daemon.CleanStateResponse 37, // 87: daemon.DaemonService.ForwardingRules:output_type -> daemon.ForwardingRulesResponse
50, // 88: daemon.DaemonService.DeleteState:output_type -> daemon.DeleteStateResponse 39, // 88: daemon.DaemonService.DebugBundle:output_type -> daemon.DebugBundleResponse
52, // 89: daemon.DaemonService.SetSyncResponsePersistence:output_type -> daemon.SetSyncResponsePersistenceResponse 41, // 89: daemon.DaemonService.GetLogLevel:output_type -> daemon.GetLogLevelResponse
56, // 90: daemon.DaemonService.TracePacket:output_type -> daemon.TracePacketResponse 43, // 90: daemon.DaemonService.SetLogLevel:output_type -> daemon.SetLogLevelResponse
58, // 91: daemon.DaemonService.SubscribeEvents:output_type -> daemon.SystemEvent 46, // 91: daemon.DaemonService.ListStates:output_type -> daemon.ListStatesResponse
60, // 92: daemon.DaemonService.GetEvents:output_type -> daemon.GetEventsResponse 48, // 92: daemon.DaemonService.CleanState:output_type -> daemon.CleanStateResponse
62, // 93: daemon.DaemonService.SwitchProfile:output_type -> daemon.SwitchProfileResponse 50, // 93: daemon.DaemonService.DeleteState:output_type -> daemon.DeleteStateResponse
64, // 94: daemon.DaemonService.SetConfig:output_type -> daemon.SetConfigResponse 52, // 94: daemon.DaemonService.SetSyncResponsePersistence:output_type -> daemon.SetSyncResponsePersistenceResponse
66, // 95: daemon.DaemonService.AddProfile:output_type -> daemon.AddProfileResponse 56, // 95: daemon.DaemonService.TracePacket:output_type -> daemon.TracePacketResponse
68, // 96: daemon.DaemonService.RemoveProfile:output_type -> daemon.RemoveProfileResponse 96, // 96: daemon.DaemonService.StartCapture:output_type -> daemon.CapturePacket
70, // 97: daemon.DaemonService.ListProfiles:output_type -> daemon.ListProfilesResponse 98, // 97: daemon.DaemonService.StartBundleCapture:output_type -> daemon.StartBundleCaptureResponse
73, // 98: daemon.DaemonService.GetActiveProfile:output_type -> daemon.GetActiveProfileResponse 100, // 98: daemon.DaemonService.StopBundleCapture:output_type -> daemon.StopBundleCaptureResponse
75, // 99: daemon.DaemonService.Logout:output_type -> daemon.LogoutResponse 58, // 99: daemon.DaemonService.SubscribeEvents:output_type -> daemon.SystemEvent
77, // 100: daemon.DaemonService.GetFeatures:output_type -> daemon.GetFeaturesResponse 60, // 100: daemon.DaemonService.GetEvents:output_type -> daemon.GetEventsResponse
79, // 101: daemon.DaemonService.TriggerUpdate:output_type -> daemon.TriggerUpdateResponse 62, // 101: daemon.DaemonService.SwitchProfile:output_type -> daemon.SwitchProfileResponse
81, // 102: daemon.DaemonService.GetPeerSSHHostKey:output_type -> daemon.GetPeerSSHHostKeyResponse 64, // 102: daemon.DaemonService.SetConfig:output_type -> daemon.SetConfigResponse
83, // 103: daemon.DaemonService.RequestJWTAuth:output_type -> daemon.RequestJWTAuthResponse 66, // 103: daemon.DaemonService.AddProfile:output_type -> daemon.AddProfileResponse
85, // 104: daemon.DaemonService.WaitJWTToken:output_type -> daemon.WaitJWTTokenResponse 68, // 104: daemon.DaemonService.RemoveProfile:output_type -> daemon.RemoveProfileResponse
87, // 105: daemon.DaemonService.StartCPUProfile:output_type -> daemon.StartCPUProfileResponse 70, // 105: daemon.DaemonService.ListProfiles:output_type -> daemon.ListProfilesResponse
89, // 106: daemon.DaemonService.StopCPUProfile:output_type -> daemon.StopCPUProfileResponse 73, // 106: daemon.DaemonService.GetActiveProfile:output_type -> daemon.GetActiveProfileResponse
7, // 107: daemon.DaemonService.NotifyOSLifecycle:output_type -> daemon.OSLifecycleResponse 75, // 107: daemon.DaemonService.Logout:output_type -> daemon.LogoutResponse
91, // 108: daemon.DaemonService.GetInstallerResult:output_type -> daemon.InstallerResultResponse 77, // 108: daemon.DaemonService.GetFeatures:output_type -> daemon.GetFeaturesResponse
93, // 109: daemon.DaemonService.ExposeService:output_type -> daemon.ExposeServiceEvent 79, // 109: daemon.DaemonService.TriggerUpdate:output_type -> daemon.TriggerUpdateResponse
73, // [73:110] is the sub-list for method output_type 81, // 110: daemon.DaemonService.GetPeerSSHHostKey:output_type -> daemon.GetPeerSSHHostKeyResponse
36, // [36:73] is the sub-list for method input_type 83, // 111: daemon.DaemonService.RequestJWTAuth:output_type -> daemon.RequestJWTAuthResponse
36, // [36:36] is the sub-list for extension type_name 85, // 112: daemon.DaemonService.WaitJWTToken:output_type -> daemon.WaitJWTTokenResponse
36, // [36:36] is the sub-list for extension extendee 87, // 113: daemon.DaemonService.StartCPUProfile:output_type -> daemon.StartCPUProfileResponse
0, // [0:36] is the sub-list for field type_name 89, // 114: daemon.DaemonService.StopCPUProfile:output_type -> daemon.StopCPUProfileResponse
7, // 115: daemon.DaemonService.NotifyOSLifecycle:output_type -> daemon.OSLifecycleResponse
91, // 116: daemon.DaemonService.GetInstallerResult:output_type -> daemon.InstallerResultResponse
93, // 117: daemon.DaemonService.ExposeService:output_type -> daemon.ExposeServiceEvent
78, // [78:118] is the sub-list for method output_type
38, // [38:78] is the sub-list for method input_type
38, // [38:38] is the sub-list for extension type_name
38, // [38:38] is the sub-list for extension extendee
0, // [0:38] is the sub-list for field type_name
} }
func init() { file_daemon_proto_init() } func init() { file_daemon_proto_init() }
@@ -6861,7 +7176,7 @@ func file_daemon_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(), GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_daemon_proto_rawDesc), len(file_daemon_proto_rawDesc)), RawDescriptor: unsafe.Slice(unsafe.StringData(file_daemon_proto_rawDesc), len(file_daemon_proto_rawDesc)),
NumEnums: 5, NumEnums: 5,
NumMessages: 93, NumMessages: 99,
NumExtensions: 0, NumExtensions: 0,
NumServices: 1, NumServices: 1,
}, },

View File

@@ -64,6 +64,17 @@ service DaemonService {
rpc TracePacket(TracePacketRequest) returns (TracePacketResponse) {} rpc TracePacket(TracePacketRequest) returns (TracePacketResponse) {}
// StartCapture begins streaming packet capture on the WireGuard interface.
// Requires --enable-capture set at service install/reconfigure time.
rpc StartCapture(StartCaptureRequest) returns (stream CapturePacket) {}
// StartBundleCapture begins capturing packets to a server-side temp file
// for inclusion in the next debug bundle. Auto-stops after the given timeout.
rpc StartBundleCapture(StartBundleCaptureRequest) returns (StartBundleCaptureResponse) {}
// StopBundleCapture stops the running bundle capture. Idempotent.
rpc StopBundleCapture(StopBundleCaptureRequest) returns (StopBundleCaptureResponse) {}
rpc SubscribeEvents(SubscribeRequest) returns (stream SystemEvent) {} rpc SubscribeEvents(SubscribeRequest) returns (stream SystemEvent) {}
rpc GetEvents(GetEventsRequest) returns (GetEventsResponse) {} rpc GetEvents(GetEventsRequest) returns (GetEventsResponse) {}
@@ -847,3 +858,26 @@ message ExposeServiceReady {
string domain = 3; string domain = 3;
bool port_auto_assigned = 4; bool port_auto_assigned = 4;
} }
message StartCaptureRequest {
bool text_output = 1;
uint32 snap_len = 2;
google.protobuf.Duration duration = 3;
string filter_expr = 4;
bool verbose = 5;
bool ascii = 6;
}
message CapturePacket {
bytes data = 1;
}
message StartBundleCaptureRequest {
// timeout auto-stops the capture after this duration.
// Clamped to a server-side maximum (10 minutes). Zero or unset defaults to the maximum.
google.protobuf.Duration timeout = 1;
}
message StartBundleCaptureResponse {}
message StopBundleCaptureRequest {}
message StopBundleCaptureResponse {}

View File

@@ -53,6 +53,14 @@ type DaemonServiceClient interface {
// SetSyncResponsePersistence enables or disables sync response persistence // SetSyncResponsePersistence enables or disables sync response persistence
SetSyncResponsePersistence(ctx context.Context, in *SetSyncResponsePersistenceRequest, opts ...grpc.CallOption) (*SetSyncResponsePersistenceResponse, error) SetSyncResponsePersistence(ctx context.Context, in *SetSyncResponsePersistenceRequest, opts ...grpc.CallOption) (*SetSyncResponsePersistenceResponse, error)
TracePacket(ctx context.Context, in *TracePacketRequest, opts ...grpc.CallOption) (*TracePacketResponse, error) TracePacket(ctx context.Context, in *TracePacketRequest, opts ...grpc.CallOption) (*TracePacketResponse, error)
// StartCapture begins streaming packet capture on the WireGuard interface.
// Requires --enable-capture set at service install/reconfigure time.
StartCapture(ctx context.Context, in *StartCaptureRequest, opts ...grpc.CallOption) (DaemonService_StartCaptureClient, error)
// StartBundleCapture begins capturing packets to a server-side temp file
// for inclusion in the next debug bundle. Auto-stops after the given timeout.
StartBundleCapture(ctx context.Context, in *StartBundleCaptureRequest, opts ...grpc.CallOption) (*StartBundleCaptureResponse, error)
// StopBundleCapture stops the running bundle capture. Idempotent.
StopBundleCapture(ctx context.Context, in *StopBundleCaptureRequest, opts ...grpc.CallOption) (*StopBundleCaptureResponse, error)
SubscribeEvents(ctx context.Context, in *SubscribeRequest, opts ...grpc.CallOption) (DaemonService_SubscribeEventsClient, error) SubscribeEvents(ctx context.Context, in *SubscribeRequest, opts ...grpc.CallOption) (DaemonService_SubscribeEventsClient, error)
GetEvents(ctx context.Context, in *GetEventsRequest, opts ...grpc.CallOption) (*GetEventsResponse, error) GetEvents(ctx context.Context, in *GetEventsRequest, opts ...grpc.CallOption) (*GetEventsResponse, error)
SwitchProfile(ctx context.Context, in *SwitchProfileRequest, opts ...grpc.CallOption) (*SwitchProfileResponse, error) SwitchProfile(ctx context.Context, in *SwitchProfileRequest, opts ...grpc.CallOption) (*SwitchProfileResponse, error)
@@ -253,8 +261,58 @@ func (c *daemonServiceClient) TracePacket(ctx context.Context, in *TracePacketRe
return out, nil return out, nil
} }
func (c *daemonServiceClient) StartCapture(ctx context.Context, in *StartCaptureRequest, opts ...grpc.CallOption) (DaemonService_StartCaptureClient, error) {
stream, err := c.cc.NewStream(ctx, &DaemonService_ServiceDesc.Streams[0], "/daemon.DaemonService/StartCapture", opts...)
if err != nil {
return nil, err
}
x := &daemonServiceStartCaptureClient{stream}
if err := x.ClientStream.SendMsg(in); err != nil {
return nil, err
}
if err := x.ClientStream.CloseSend(); err != nil {
return nil, err
}
return x, nil
}
type DaemonService_StartCaptureClient interface {
Recv() (*CapturePacket, error)
grpc.ClientStream
}
type daemonServiceStartCaptureClient struct {
grpc.ClientStream
}
func (x *daemonServiceStartCaptureClient) Recv() (*CapturePacket, error) {
m := new(CapturePacket)
if err := x.ClientStream.RecvMsg(m); err != nil {
return nil, err
}
return m, nil
}
func (c *daemonServiceClient) StartBundleCapture(ctx context.Context, in *StartBundleCaptureRequest, opts ...grpc.CallOption) (*StartBundleCaptureResponse, error) {
out := new(StartBundleCaptureResponse)
err := c.cc.Invoke(ctx, "/daemon.DaemonService/StartBundleCapture", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *daemonServiceClient) StopBundleCapture(ctx context.Context, in *StopBundleCaptureRequest, opts ...grpc.CallOption) (*StopBundleCaptureResponse, error) {
out := new(StopBundleCaptureResponse)
err := c.cc.Invoke(ctx, "/daemon.DaemonService/StopBundleCapture", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *daemonServiceClient) SubscribeEvents(ctx context.Context, in *SubscribeRequest, opts ...grpc.CallOption) (DaemonService_SubscribeEventsClient, error) { func (c *daemonServiceClient) SubscribeEvents(ctx context.Context, in *SubscribeRequest, opts ...grpc.CallOption) (DaemonService_SubscribeEventsClient, error) {
stream, err := c.cc.NewStream(ctx, &DaemonService_ServiceDesc.Streams[0], "/daemon.DaemonService/SubscribeEvents", opts...) stream, err := c.cc.NewStream(ctx, &DaemonService_ServiceDesc.Streams[1], "/daemon.DaemonService/SubscribeEvents", opts...)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -439,7 +497,7 @@ func (c *daemonServiceClient) GetInstallerResult(ctx context.Context, in *Instal
} }
func (c *daemonServiceClient) ExposeService(ctx context.Context, in *ExposeServiceRequest, opts ...grpc.CallOption) (DaemonService_ExposeServiceClient, error) { func (c *daemonServiceClient) ExposeService(ctx context.Context, in *ExposeServiceRequest, opts ...grpc.CallOption) (DaemonService_ExposeServiceClient, error) {
stream, err := c.cc.NewStream(ctx, &DaemonService_ServiceDesc.Streams[1], "/daemon.DaemonService/ExposeService", opts...) stream, err := c.cc.NewStream(ctx, &DaemonService_ServiceDesc.Streams[2], "/daemon.DaemonService/ExposeService", opts...)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -509,6 +567,14 @@ type DaemonServiceServer interface {
// SetSyncResponsePersistence enables or disables sync response persistence // SetSyncResponsePersistence enables or disables sync response persistence
SetSyncResponsePersistence(context.Context, *SetSyncResponsePersistenceRequest) (*SetSyncResponsePersistenceResponse, error) SetSyncResponsePersistence(context.Context, *SetSyncResponsePersistenceRequest) (*SetSyncResponsePersistenceResponse, error)
TracePacket(context.Context, *TracePacketRequest) (*TracePacketResponse, error) TracePacket(context.Context, *TracePacketRequest) (*TracePacketResponse, error)
// StartCapture begins streaming packet capture on the WireGuard interface.
// Requires --enable-capture set at service install/reconfigure time.
StartCapture(*StartCaptureRequest, DaemonService_StartCaptureServer) error
// StartBundleCapture begins capturing packets to a server-side temp file
// for inclusion in the next debug bundle. Auto-stops after the given timeout.
StartBundleCapture(context.Context, *StartBundleCaptureRequest) (*StartBundleCaptureResponse, error)
// StopBundleCapture stops the running bundle capture. Idempotent.
StopBundleCapture(context.Context, *StopBundleCaptureRequest) (*StopBundleCaptureResponse, error)
SubscribeEvents(*SubscribeRequest, DaemonService_SubscribeEventsServer) error SubscribeEvents(*SubscribeRequest, DaemonService_SubscribeEventsServer) error
GetEvents(context.Context, *GetEventsRequest) (*GetEventsResponse, error) GetEvents(context.Context, *GetEventsRequest) (*GetEventsResponse, error)
SwitchProfile(context.Context, *SwitchProfileRequest) (*SwitchProfileResponse, error) SwitchProfile(context.Context, *SwitchProfileRequest) (*SwitchProfileResponse, error)
@@ -598,6 +664,15 @@ func (UnimplementedDaemonServiceServer) SetSyncResponsePersistence(context.Conte
func (UnimplementedDaemonServiceServer) TracePacket(context.Context, *TracePacketRequest) (*TracePacketResponse, error) { func (UnimplementedDaemonServiceServer) TracePacket(context.Context, *TracePacketRequest) (*TracePacketResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method TracePacket not implemented") return nil, status.Errorf(codes.Unimplemented, "method TracePacket not implemented")
} }
func (UnimplementedDaemonServiceServer) StartCapture(*StartCaptureRequest, DaemonService_StartCaptureServer) error {
return status.Errorf(codes.Unimplemented, "method StartCapture not implemented")
}
func (UnimplementedDaemonServiceServer) StartBundleCapture(context.Context, *StartBundleCaptureRequest) (*StartBundleCaptureResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method StartBundleCapture not implemented")
}
func (UnimplementedDaemonServiceServer) StopBundleCapture(context.Context, *StopBundleCaptureRequest) (*StopBundleCaptureResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method StopBundleCapture not implemented")
}
func (UnimplementedDaemonServiceServer) SubscribeEvents(*SubscribeRequest, DaemonService_SubscribeEventsServer) error { func (UnimplementedDaemonServiceServer) SubscribeEvents(*SubscribeRequest, DaemonService_SubscribeEventsServer) error {
return status.Errorf(codes.Unimplemented, "method SubscribeEvents not implemented") return status.Errorf(codes.Unimplemented, "method SubscribeEvents not implemented")
} }
@@ -992,6 +1067,63 @@ func _DaemonService_TracePacket_Handler(srv interface{}, ctx context.Context, de
return interceptor(ctx, in, info, handler) return interceptor(ctx, in, info, handler)
} }
func _DaemonService_StartCapture_Handler(srv interface{}, stream grpc.ServerStream) error {
m := new(StartCaptureRequest)
if err := stream.RecvMsg(m); err != nil {
return err
}
return srv.(DaemonServiceServer).StartCapture(m, &daemonServiceStartCaptureServer{stream})
}
type DaemonService_StartCaptureServer interface {
Send(*CapturePacket) error
grpc.ServerStream
}
type daemonServiceStartCaptureServer struct {
grpc.ServerStream
}
func (x *daemonServiceStartCaptureServer) Send(m *CapturePacket) error {
return x.ServerStream.SendMsg(m)
}
func _DaemonService_StartBundleCapture_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(StartBundleCaptureRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(DaemonServiceServer).StartBundleCapture(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/daemon.DaemonService/StartBundleCapture",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DaemonServiceServer).StartBundleCapture(ctx, req.(*StartBundleCaptureRequest))
}
return interceptor(ctx, in, info, handler)
}
func _DaemonService_StopBundleCapture_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(StopBundleCaptureRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(DaemonServiceServer).StopBundleCapture(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/daemon.DaemonService/StopBundleCapture",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(DaemonServiceServer).StopBundleCapture(ctx, req.(*StopBundleCaptureRequest))
}
return interceptor(ctx, in, info, handler)
}
func _DaemonService_SubscribeEvents_Handler(srv interface{}, stream grpc.ServerStream) error { func _DaemonService_SubscribeEvents_Handler(srv interface{}, stream grpc.ServerStream) error {
m := new(SubscribeRequest) m := new(SubscribeRequest)
if err := stream.RecvMsg(m); err != nil { if err := stream.RecvMsg(m); err != nil {
@@ -1419,6 +1551,14 @@ var DaemonService_ServiceDesc = grpc.ServiceDesc{
MethodName: "TracePacket", MethodName: "TracePacket",
Handler: _DaemonService_TracePacket_Handler, Handler: _DaemonService_TracePacket_Handler,
}, },
{
MethodName: "StartBundleCapture",
Handler: _DaemonService_StartBundleCapture_Handler,
},
{
MethodName: "StopBundleCapture",
Handler: _DaemonService_StopBundleCapture_Handler,
},
{ {
MethodName: "GetEvents", MethodName: "GetEvents",
Handler: _DaemonService_GetEvents_Handler, Handler: _DaemonService_GetEvents_Handler,
@@ -1489,6 +1629,11 @@ var DaemonService_ServiceDesc = grpc.ServiceDesc{
}, },
}, },
Streams: []grpc.StreamDesc{ Streams: []grpc.StreamDesc{
{
StreamName: "StartCapture",
Handler: _DaemonService_StartCapture_Handler,
ServerStreams: true,
},
{ {
StreamName: "SubscribeEvents", StreamName: "SubscribeEvents",
Handler: _DaemonService_SubscribeEvents_Handler, Handler: _DaemonService_SubscribeEvents_Handler,

325
client/server/capture.go Normal file
View File

@@ -0,0 +1,325 @@
package server
import (
"context"
"io"
"os"
"sync"
"time"
log "github.com/sirupsen/logrus"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/internal"
"github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/util/capture"
)
const maxBundleCaptureDuration = 10 * time.Minute
// bundleCapture holds the state of an in-progress capture destined for the
// debug bundle. The lifecycle is:
//
// StartBundleCapture → capture running, writing to temp file
// StopBundleCapture → capture stopped, temp file available
// DebugBundle → temp file included in zip, then cleaned up
type bundleCapture struct {
mu sync.Mutex
sess *capture.Session
file *os.File
engine *internal.Engine
cancel context.CancelFunc
stopped bool
}
// stop halts the capture session and closes the pcap writer. Idempotent.
func (bc *bundleCapture) stop() {
bc.mu.Lock()
defer bc.mu.Unlock()
if bc.stopped {
return
}
bc.stopped = true
if bc.cancel != nil {
bc.cancel()
}
if bc.engine != nil {
if err := bc.engine.SetCapture(nil); err != nil {
log.Debugf("clear bundle capture: %v", err)
}
}
if bc.sess != nil {
bc.sess.Stop()
}
}
// path returns the temp file path, or "" if no file exists.
func (bc *bundleCapture) path() string {
if bc.file == nil {
return ""
}
return bc.file.Name()
}
// cleanup removes the temp file.
func (bc *bundleCapture) cleanup() {
if bc.file == nil {
return
}
name := bc.file.Name()
if err := bc.file.Close(); err != nil {
log.Debugf("close bundle capture file: %v", err)
}
if err := os.Remove(name); err != nil && !os.IsNotExist(err) {
log.Debugf("remove bundle capture file: %v", err)
}
bc.file = nil
}
// StartCapture streams a pcap or text packet capture over gRPC.
// Gated by the --enable-capture service flag.
func (s *Server) StartCapture(req *proto.StartCaptureRequest, stream proto.DaemonService_StartCaptureServer) error {
if !s.captureEnabled {
return status.Error(codes.PermissionDenied,
"packet capture is disabled; reinstall or reconfigure the service with --enable-capture")
}
engine, err := s.getCaptureEngine()
if err != nil {
return err
}
matcher, err := parseCaptureFilter(req)
if err != nil {
return status.Errorf(codes.InvalidArgument, "invalid filter: %v", err)
}
pr, pw := io.Pipe()
opts := capture.Options{
Matcher: matcher,
SnapLen: req.GetSnapLen(),
Verbose: req.GetVerbose(),
ASCII: req.GetAscii(),
}
if req.GetTextOutput() {
opts.TextOutput = pw
} else {
opts.Output = pw
}
sess, err := capture.NewSession(opts)
if err != nil {
pw.Close()
return status.Errorf(codes.Internal, "create capture session: %v", err)
}
if err := engine.SetCapture(sess); err != nil {
sess.Stop()
pw.Close()
return status.Errorf(codes.Internal, "set capture: %v", err)
}
// Send an empty initial message to signal that the capture was accepted.
// The client waits for this before printing the banner, so it must arrive
// before any packet data.
if err := stream.Send(&proto.CapturePacket{}); err != nil {
if clearErr := engine.SetCapture(nil); clearErr != nil {
log.Debugf("clear capture after send failure: %v", clearErr)
}
sess.Stop()
pw.Close()
return status.Errorf(codes.Internal, "send initial message: %v", err)
}
ctx := stream.Context()
if d := req.GetDuration(); d != nil {
dur := d.AsDuration()
if dur < 0 {
if clearErr := engine.SetCapture(nil); clearErr != nil {
log.Debugf("clear capture: %v", clearErr)
}
sess.Stop()
pw.Close()
return status.Errorf(codes.InvalidArgument, "duration must not be negative")
}
if dur > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, dur)
defer cancel()
}
}
go func() {
<-ctx.Done()
if err := engine.SetCapture(nil); err != nil {
log.Debugf("clear capture: %v", err)
}
sess.Stop()
pw.Close()
}()
defer pr.Close()
log.Infof("packet capture started (text=%v, expr=%q)", req.GetTextOutput(), req.GetFilterExpr())
defer func() {
stats := sess.Stats()
log.Infof("packet capture stopped: %d packets, %d bytes, %d dropped",
stats.Packets, stats.Bytes, stats.Dropped)
}()
return streamToGRPC(pr, stream)
}
func streamToGRPC(r io.Reader, stream proto.DaemonService_StartCaptureServer) error {
buf := make([]byte, 32*1024)
for {
n, readErr := r.Read(buf)
if n > 0 {
if err := stream.Send(&proto.CapturePacket{Data: buf[:n]}); err != nil {
log.Debugf("capture stream send: %v", err)
return nil //nolint:nilerr // client disconnected
}
}
if readErr != nil {
return nil //nolint:nilerr // pipe closed, capture stopped normally
}
}
}
// StartBundleCapture begins capturing packets to a server-side temp file for
// inclusion in the next debug bundle. Not gated by --enable-capture since the
// output stays on the server (same trust level as CPU profiling).
//
// A timeout auto-stops the capture as a safety net if StopBundleCapture is
// never called (e.g. CLI crash).
func (s *Server) StartBundleCapture(_ context.Context, req *proto.StartBundleCaptureRequest) (*proto.StartBundleCaptureResponse, error) {
s.mutex.Lock()
defer s.mutex.Unlock()
s.stopBundleCaptureLocked()
s.cleanupBundleCapture()
engine, err := s.getCaptureEngineLocked()
if err != nil {
// Not fatal: kernel mode or not connected. Log and return success
// so the debug bundle still generates without capture data.
log.Warnf("packet capture unavailable, skipping: %v", err)
return &proto.StartBundleCaptureResponse{}, nil
}
timeout := req.GetTimeout().AsDuration()
if timeout <= 0 || timeout > maxBundleCaptureDuration {
timeout = maxBundleCaptureDuration
}
f, err := os.CreateTemp("", "netbird.capture.*.pcap")
if err != nil {
return nil, status.Errorf(codes.Internal, "create temp file: %v", err)
}
sess, err := capture.NewSession(capture.Options{Output: f})
if err != nil {
f.Close()
os.Remove(f.Name())
return nil, status.Errorf(codes.Internal, "create capture session: %v", err)
}
if err := engine.SetCapture(sess); err != nil {
sess.Stop()
f.Close()
os.Remove(f.Name())
log.Warnf("packet capture unavailable (no filtered device), skipping: %v", err)
return &proto.StartBundleCaptureResponse{}, nil
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
bc := &bundleCapture{
sess: sess,
file: f,
engine: engine,
cancel: cancel,
}
go func() {
<-ctx.Done()
bc.stop()
log.Infof("bundle capture auto-stopped after timeout")
}()
s.bundleCapture = bc
log.Infof("bundle capture started (timeout=%s, file=%s)", timeout, f.Name())
return &proto.StartBundleCaptureResponse{}, nil
}
// StopBundleCapture stops the running bundle capture. Idempotent.
func (s *Server) StopBundleCapture(_ context.Context, _ *proto.StopBundleCaptureRequest) (*proto.StopBundleCaptureResponse, error) {
s.mutex.Lock()
defer s.mutex.Unlock()
s.stopBundleCaptureLocked()
return &proto.StopBundleCaptureResponse{}, nil
}
// stopBundleCaptureLocked stops the bundle capture if running. Must hold s.mutex.
func (s *Server) stopBundleCaptureLocked() {
if s.bundleCapture == nil {
return
}
s.bundleCapture.stop()
stats := s.bundleCapture.sess.Stats()
log.Infof("bundle capture stopped: %d packets, %d bytes, %d dropped",
stats.Packets, stats.Bytes, stats.Dropped)
}
// bundleCapturePath returns the temp file path if a capture has been taken,
// stops any running capture, and returns "". Called from DebugBundle.
// Must hold s.mutex.
func (s *Server) bundleCapturePath() string {
if s.bundleCapture == nil {
return ""
}
s.bundleCapture.stop()
return s.bundleCapture.path()
}
// cleanupBundleCapture removes the temp file and clears state. Must hold s.mutex.
func (s *Server) cleanupBundleCapture() {
if s.bundleCapture == nil {
return
}
s.bundleCapture.cleanup()
s.bundleCapture = nil
}
func (s *Server) getCaptureEngine() (*internal.Engine, error) {
s.mutex.Lock()
defer s.mutex.Unlock()
return s.getCaptureEngineLocked()
}
func (s *Server) getCaptureEngineLocked() (*internal.Engine, error) {
if s.connectClient == nil {
return nil, status.Error(codes.FailedPrecondition, "client not connected")
}
engine := s.connectClient.Engine()
if engine == nil {
return nil, status.Error(codes.FailedPrecondition, "engine not initialized")
}
return engine, nil
}
// parseCaptureFilter returns a Matcher from the request.
// Returns nil (match all) when no filter expression is set.
func parseCaptureFilter(req *proto.StartCaptureRequest) (capture.Matcher, error) {
expr := req.GetFilterExpr()
if expr == "" {
return nil, nil //nolint:nilnil // nil Matcher means "match all"
}
return capture.ParseFilter(expr)
}

View File

@@ -43,7 +43,9 @@ func (s *Server) DebugBundle(_ context.Context, req *proto.DebugBundleRequest) (
}() }()
} }
// Prepare refresh callback for health probes capturePath := s.bundleCapturePath()
defer s.cleanupBundleCapture()
var refreshStatus func() var refreshStatus func()
if s.connectClient != nil { if s.connectClient != nil {
engine := s.connectClient.Engine() engine := s.connectClient.Engine()
@@ -62,6 +64,7 @@ func (s *Server) DebugBundle(_ context.Context, req *proto.DebugBundleRequest) (
SyncResponse: syncResponse, SyncResponse: syncResponse,
LogPath: s.logFile, LogPath: s.logFile,
CPUProfile: cpuProfileData, CPUProfile: cpuProfileData,
CapturePath: capturePath,
RefreshStatus: refreshStatus, RefreshStatus: refreshStatus,
ClientMetrics: clientMetrics, ClientMetrics: clientMetrics,
}, },

View File

@@ -88,6 +88,8 @@ type Server struct {
profileManager *profilemanager.ServiceManager profileManager *profilemanager.ServiceManager
profilesDisabled bool profilesDisabled bool
updateSettingsDisabled bool updateSettingsDisabled bool
captureEnabled bool
bundleCapture *bundleCapture
sleepHandler *sleephandler.SleepHandler sleepHandler *sleephandler.SleepHandler
@@ -104,7 +106,7 @@ type oauthAuthFlow struct {
} }
// New server instance constructor. // New server instance constructor.
func New(ctx context.Context, logFile string, configFile string, profilesDisabled bool, updateSettingsDisabled bool) *Server { func New(ctx context.Context, logFile string, configFile string, profilesDisabled bool, updateSettingsDisabled bool, captureEnabled bool) *Server {
s := &Server{ s := &Server{
rootCtx: ctx, rootCtx: ctx,
logFile: logFile, logFile: logFile,
@@ -113,6 +115,7 @@ func New(ctx context.Context, logFile string, configFile string, profilesDisable
profileManager: profilemanager.NewServiceManager(configFile), profileManager: profilemanager.NewServiceManager(configFile),
profilesDisabled: profilesDisabled, profilesDisabled: profilesDisabled,
updateSettingsDisabled: updateSettingsDisabled, updateSettingsDisabled: updateSettingsDisabled,
captureEnabled: captureEnabled,
jwtCache: newJWTCache(), jwtCache: newJWTCache(),
} }
agent := &serverAgent{s} agent := &serverAgent{s}

View File

@@ -103,7 +103,7 @@ func TestConnectWithRetryRuns(t *testing.T) {
t.Fatalf("failed to set active profile state: %v", err) t.Fatalf("failed to set active profile state: %v", err)
} }
s := New(ctx, "debug", "", false, false) s := New(ctx, "debug", "", false, false, false)
s.config = config s.config = config
@@ -164,7 +164,7 @@ func TestServer_Up(t *testing.T) {
t.Fatalf("failed to set active profile state: %v", err) t.Fatalf("failed to set active profile state: %v", err)
} }
s := New(ctx, "console", "", false, false) s := New(ctx, "console", "", false, false, false)
err = s.Start() err = s.Start()
require.NoError(t, err) require.NoError(t, err)
@@ -234,7 +234,7 @@ func TestServer_SubcribeEvents(t *testing.T) {
t.Fatalf("failed to set active profile state: %v", err) t.Fatalf("failed to set active profile state: %v", err)
} }
s := New(ctx, "console", "", false, false) s := New(ctx, "console", "", false, false, false)
err = s.Start() err = s.Start()
require.NoError(t, err) require.NoError(t, err)

View File

@@ -53,7 +53,7 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
ctx := context.Background() ctx := context.Background()
s := New(ctx, "console", "", false, false) s := New(ctx, "console", "", false, false, false)
rosenpassEnabled := true rosenpassEnabled := true
rosenpassPermissive := true rosenpassPermissive := true

View File

@@ -16,6 +16,7 @@ import (
"fyne.io/fyne/v2/widget" "fyne.io/fyne/v2/widget"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"github.com/skratchdot/open-golang/open" "github.com/skratchdot/open-golang/open"
"google.golang.org/protobuf/types/known/durationpb"
"github.com/netbirdio/netbird/client/internal" "github.com/netbirdio/netbird/client/internal"
"github.com/netbirdio/netbird/client/proto" "github.com/netbirdio/netbird/client/proto"
@@ -38,6 +39,7 @@ type debugCollectionParams struct {
upload bool upload bool
uploadURL string uploadURL string
enablePersistence bool enablePersistence bool
capture bool
} }
// UI components for progress tracking // UI components for progress tracking
@@ -51,25 +53,58 @@ type progressUI struct {
func (s *serviceClient) showDebugUI() { func (s *serviceClient) showDebugUI() {
w := s.app.NewWindow("NetBird Debug") w := s.app.NewWindow("NetBird Debug")
w.SetOnClosed(s.cancel) w.SetOnClosed(s.cancel)
w.Resize(fyne.NewSize(600, 500)) w.Resize(fyne.NewSize(600, 500))
w.SetFixedSize(true) w.SetFixedSize(true)
anonymizeCheck := widget.NewCheck("Anonymize sensitive information (public IPs, domains, ...)", nil) anonymizeCheck := widget.NewCheck("Anonymize sensitive information (public IPs, domains, ...)", nil)
systemInfoCheck := widget.NewCheck("Include system information (routes, interfaces, ...)", nil) systemInfoCheck := widget.NewCheck("Include system information (routes, interfaces, ...)", nil)
systemInfoCheck.SetChecked(true) systemInfoCheck.SetChecked(true)
captureCheck := widget.NewCheck("Include packet capture", nil)
uploadCheck := widget.NewCheck("Upload bundle automatically after creation", nil) uploadCheck := widget.NewCheck("Upload bundle automatically after creation", nil)
uploadCheck.SetChecked(true) uploadCheck.SetChecked(true)
uploadURLLabel := widget.NewLabel("Debug upload URL:") uploadURLContainer, uploadURL := s.buildUploadSection(uploadCheck)
debugModeContainer, runForDurationCheck, durationInput, noteLabel := s.buildDurationSection()
statusLabel := widget.NewLabel("")
statusLabel.Hide()
progressBar := widget.NewProgressBar()
progressBar.Hide()
createButton := widget.NewButton("Create Debug Bundle", nil)
uiControls := []fyne.Disableable{
anonymizeCheck, systemInfoCheck, captureCheck,
uploadCheck, uploadURL, runForDurationCheck, durationInput, createButton,
}
createButton.OnTapped = s.getCreateHandler(
statusLabel, progressBar, uploadCheck, uploadURL,
anonymizeCheck, systemInfoCheck, captureCheck,
runForDurationCheck, durationInput, uiControls, w,
)
content := container.NewVBox(
widget.NewLabel("Create a debug bundle to help troubleshoot issues with NetBird"),
widget.NewLabel(""),
anonymizeCheck, systemInfoCheck, captureCheck,
uploadCheck, uploadURLContainer,
widget.NewLabel(""),
debugModeContainer, noteLabel,
widget.NewLabel(""),
statusLabel, progressBar, createButton,
)
w.SetContent(container.NewPadded(content))
w.Show()
}
func (s *serviceClient) buildUploadSection(uploadCheck *widget.Check) (*fyne.Container, *widget.Entry) {
uploadURL := widget.NewEntry() uploadURL := widget.NewEntry()
uploadURL.SetText(uptypes.DefaultBundleURL) uploadURL.SetText(uptypes.DefaultBundleURL)
uploadURL.SetPlaceHolder("Enter upload URL") uploadURL.SetPlaceHolder("Enter upload URL")
uploadURLContainer := container.NewVBox( uploadURLContainer := container.NewVBox(widget.NewLabel("Debug upload URL:"), uploadURL)
uploadURLLabel,
uploadURL,
)
uploadCheck.OnChanged = func(checked bool) { uploadCheck.OnChanged = func(checked bool) {
if checked { if checked {
@@ -78,13 +113,14 @@ func (s *serviceClient) showDebugUI() {
uploadURLContainer.Hide() uploadURLContainer.Hide()
} }
} }
return uploadURLContainer, uploadURL
}
debugModeContainer := container.NewHBox() func (s *serviceClient) buildDurationSection() (*fyne.Container, *widget.Check, *widget.Entry, *widget.Label) {
runForDurationCheck := widget.NewCheck("Run with trace logs before creating bundle", nil) runForDurationCheck := widget.NewCheck("Run with trace logs before creating bundle", nil)
runForDurationCheck.SetChecked(true) runForDurationCheck.SetChecked(true)
forLabel := widget.NewLabel("for") forLabel := widget.NewLabel("for")
durationInput := widget.NewEntry() durationInput := widget.NewEntry()
durationInput.SetText("1") durationInput.SetText("1")
minutesLabel := widget.NewLabel("minute") minutesLabel := widget.NewLabel("minute")
@@ -108,63 +144,8 @@ func (s *serviceClient) showDebugUI() {
} }
} }
debugModeContainer.Add(runForDurationCheck) modeContainer := container.NewHBox(runForDurationCheck, forLabel, durationInput, minutesLabel)
debugModeContainer.Add(forLabel) return modeContainer, runForDurationCheck, durationInput, noteLabel
debugModeContainer.Add(durationInput)
debugModeContainer.Add(minutesLabel)
statusLabel := widget.NewLabel("")
statusLabel.Hide()
progressBar := widget.NewProgressBar()
progressBar.Hide()
createButton := widget.NewButton("Create Debug Bundle", nil)
// UI controls that should be disabled during debug collection
uiControls := []fyne.Disableable{
anonymizeCheck,
systemInfoCheck,
uploadCheck,
uploadURL,
runForDurationCheck,
durationInput,
createButton,
}
createButton.OnTapped = s.getCreateHandler(
statusLabel,
progressBar,
uploadCheck,
uploadURL,
anonymizeCheck,
systemInfoCheck,
runForDurationCheck,
durationInput,
uiControls,
w,
)
content := container.NewVBox(
widget.NewLabel("Create a debug bundle to help troubleshoot issues with NetBird"),
widget.NewLabel(""),
anonymizeCheck,
systemInfoCheck,
uploadCheck,
uploadURLContainer,
widget.NewLabel(""),
debugModeContainer,
noteLabel,
widget.NewLabel(""),
statusLabel,
progressBar,
createButton,
)
paddedContent := container.NewPadded(content)
w.SetContent(paddedContent)
w.Show()
} }
func validateMinute(s string, minutesLabel *widget.Label) error { func validateMinute(s string, minutesLabel *widget.Label) error {
@@ -200,6 +181,7 @@ func (s *serviceClient) getCreateHandler(
uploadURL *widget.Entry, uploadURL *widget.Entry,
anonymizeCheck *widget.Check, anonymizeCheck *widget.Check,
systemInfoCheck *widget.Check, systemInfoCheck *widget.Check,
captureCheck *widget.Check,
runForDurationCheck *widget.Check, runForDurationCheck *widget.Check,
duration *widget.Entry, duration *widget.Entry,
uiControls []fyne.Disableable, uiControls []fyne.Disableable,
@@ -222,6 +204,7 @@ func (s *serviceClient) getCreateHandler(
params := &debugCollectionParams{ params := &debugCollectionParams{
anonymize: anonymizeCheck.Checked, anonymize: anonymizeCheck.Checked,
systemInfo: systemInfoCheck.Checked, systemInfo: systemInfoCheck.Checked,
capture: captureCheck.Checked,
upload: uploadCheck.Checked, upload: uploadCheck.Checked,
uploadURL: url, uploadURL: url,
enablePersistence: true, enablePersistence: true,
@@ -253,10 +236,7 @@ func (s *serviceClient) getCreateHandler(
statusLabel.SetText("Creating debug bundle...") statusLabel.SetText("Creating debug bundle...")
go s.handleDebugCreation( go s.handleDebugCreation(
anonymizeCheck.Checked, params,
systemInfoCheck.Checked,
uploadCheck.Checked,
url,
statusLabel, statusLabel,
uiControls, uiControls,
w, w,
@@ -371,7 +351,7 @@ func startProgressTracker(ctx context.Context, wg *sync.WaitGroup, duration time
func (s *serviceClient) configureServiceForDebug( func (s *serviceClient) configureServiceForDebug(
conn proto.DaemonServiceClient, conn proto.DaemonServiceClient,
state *debugInitialState, state *debugInitialState,
enablePersistence bool, params *debugCollectionParams,
) { ) {
if state.wasDown { if state.wasDown {
if _, err := conn.Up(s.ctx, &proto.UpRequest{}); err != nil { if _, err := conn.Up(s.ctx, &proto.UpRequest{}); err != nil {
@@ -397,7 +377,7 @@ func (s *serviceClient) configureServiceForDebug(
time.Sleep(time.Second) time.Sleep(time.Second)
} }
if enablePersistence { if params.enablePersistence {
if _, err := conn.SetSyncResponsePersistence(s.ctx, &proto.SetSyncResponsePersistenceRequest{ if _, err := conn.SetSyncResponsePersistence(s.ctx, &proto.SetSyncResponsePersistenceRequest{
Enabled: true, Enabled: true,
}); err != nil { }); err != nil {
@@ -417,6 +397,26 @@ func (s *serviceClient) configureServiceForDebug(
if _, err := conn.StartCPUProfile(s.ctx, &proto.StartCPUProfileRequest{}); err != nil { if _, err := conn.StartCPUProfile(s.ctx, &proto.StartCPUProfileRequest{}); err != nil {
log.Warnf("failed to start CPU profiling: %v", err) log.Warnf("failed to start CPU profiling: %v", err)
} }
s.startBundleCaptureIfEnabled(conn, params)
}
func (s *serviceClient) startBundleCaptureIfEnabled(conn proto.DaemonServiceClient, params *debugCollectionParams) {
if !params.capture {
return
}
const maxCapture = 10 * time.Minute
timeout := params.duration + 30*time.Second
if timeout > maxCapture {
timeout = maxCapture
log.Warnf("packet capture clamped to %s (server maximum)", maxCapture)
}
if _, err := conn.StartBundleCapture(s.ctx, &proto.StartBundleCaptureRequest{
Timeout: durationpb.New(timeout),
}); err != nil {
log.Warnf("failed to start bundle capture: %v", err)
}
} }
func (s *serviceClient) collectDebugData( func (s *serviceClient) collectDebugData(
@@ -430,7 +430,7 @@ func (s *serviceClient) collectDebugData(
var wg sync.WaitGroup var wg sync.WaitGroup
startProgressTracker(ctx, &wg, params.duration, progress) startProgressTracker(ctx, &wg, params.duration, progress)
s.configureServiceForDebug(conn, state, params.enablePersistence) s.configureServiceForDebug(conn, state, params)
wg.Wait() wg.Wait()
progress.progressBar.Hide() progress.progressBar.Hide()
@@ -440,6 +440,14 @@ func (s *serviceClient) collectDebugData(
log.Warnf("failed to stop CPU profiling: %v", err) log.Warnf("failed to stop CPU profiling: %v", err)
} }
if params.capture {
stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if _, err := conn.StopBundleCapture(stopCtx, &proto.StopBundleCaptureRequest{}); err != nil {
log.Warnf("failed to stop bundle capture: %v", err)
}
}
return nil return nil
} }
@@ -520,18 +528,37 @@ func handleError(progress *progressUI, errMsg string) {
} }
func (s *serviceClient) handleDebugCreation( func (s *serviceClient) handleDebugCreation(
anonymize bool, params *debugCollectionParams,
systemInfo bool,
upload bool,
uploadURL string,
statusLabel *widget.Label, statusLabel *widget.Label,
uiControls []fyne.Disableable, uiControls []fyne.Disableable,
w fyne.Window, w fyne.Window,
) { ) {
log.Infof("Creating debug bundle (Anonymized: %v, System Info: %v, Upload Attempt: %v)...", conn, err := s.getSrvClient(failFastTimeout)
anonymize, systemInfo, upload) if err != nil {
log.Errorf("Failed to get client for debug: %v", err)
statusLabel.SetText(fmt.Sprintf("Error: %v", err))
enableUIControls(uiControls)
return
}
resp, err := s.createDebugBundle(anonymize, systemInfo, uploadURL) if params.capture {
if _, err := conn.StartBundleCapture(s.ctx, &proto.StartBundleCaptureRequest{
Timeout: durationpb.New(30 * time.Second),
}); err != nil {
log.Warnf("failed to start bundle capture: %v", err)
} else {
defer func() {
stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if _, err := conn.StopBundleCapture(stopCtx, &proto.StopBundleCaptureRequest{}); err != nil {
log.Warnf("failed to stop bundle capture: %v", err)
}
}()
time.Sleep(2 * time.Second)
}
}
resp, err := s.createDebugBundle(params.anonymize, params.systemInfo, params.uploadURL)
if err != nil { if err != nil {
log.Errorf("Failed to create debug bundle: %v", err) log.Errorf("Failed to create debug bundle: %v", err)
statusLabel.SetText(fmt.Sprintf("Error creating bundle: %v", err)) statusLabel.SetText(fmt.Sprintf("Error creating bundle: %v", err))
@@ -543,7 +570,7 @@ func (s *serviceClient) handleDebugCreation(
uploadFailureReason := resp.GetUploadFailureReason() uploadFailureReason := resp.GetUploadFailureReason()
uploadedKey := resp.GetUploadedKey() uploadedKey := resp.GetUploadedKey()
if upload { if params.upload {
if uploadFailureReason != "" { if uploadFailureReason != "" {
showUploadFailedDialog(w, localPath, uploadFailureReason) showUploadFailedDialog(w, localPath, uploadFailureReason)
} else { } else {

View File

@@ -5,6 +5,7 @@ package main
import ( import (
"context" "context"
"fmt" "fmt"
"sync"
"syscall/js" "syscall/js"
"time" "time"
@@ -14,6 +15,7 @@ import (
netbird "github.com/netbirdio/netbird/client/embed" netbird "github.com/netbirdio/netbird/client/embed"
sshdetection "github.com/netbirdio/netbird/client/ssh/detection" sshdetection "github.com/netbirdio/netbird/client/ssh/detection"
nbstatus "github.com/netbirdio/netbird/client/status" nbstatus "github.com/netbirdio/netbird/client/status"
wasmcapture "github.com/netbirdio/netbird/client/wasm/internal/capture"
"github.com/netbirdio/netbird/client/wasm/internal/http" "github.com/netbirdio/netbird/client/wasm/internal/http"
"github.com/netbirdio/netbird/client/wasm/internal/rdp" "github.com/netbirdio/netbird/client/wasm/internal/rdp"
"github.com/netbirdio/netbird/client/wasm/internal/ssh" "github.com/netbirdio/netbird/client/wasm/internal/ssh"
@@ -459,6 +461,95 @@ func createSetLogLevelMethod(client *netbird.Client) js.Func {
}) })
} }
// createStartCaptureMethod creates the programmable packet capture method.
// Returns a JS interface with onpacket callback and stop() method.
//
// Usage from JavaScript:
//
// const cap = await client.startCapture({ filter: "tcp port 443", verbose: true })
// cap.onpacket = (line) => console.log(line)
// const stats = cap.stop()
func createStartCaptureMethod(client *netbird.Client) js.Func {
return js.FuncOf(func(_ js.Value, args []js.Value) any {
var opts js.Value
if len(args) > 0 {
opts = args[0]
}
return createPromise(func(resolve, reject js.Value) {
iface, err := wasmcapture.Start(client, opts)
if err != nil {
reject.Invoke(js.ValueOf(fmt.Sprintf("start capture: %v", err)))
return
}
resolve.Invoke(iface)
})
})
}
// captureMethods returns capture() and stopCapture() that share state for
// the console-log shortcut. capture() logs packets to the browser console
// and stopCapture() ends it, like Ctrl+C on the CLI.
//
// Usage from browser devtools console:
//
// await client.capture() // capture all packets
// await client.capture("tcp") // capture with filter
// await client.capture({filter: "host 10.0.0.1", verbose: true})
// client.stopCapture() // stop and print stats
func captureMethods(client *netbird.Client) (startFn, stopFn js.Func) {
var mu sync.Mutex
var active *wasmcapture.Handle
startFn = js.FuncOf(func(_ js.Value, args []js.Value) any {
var opts js.Value
if len(args) > 0 {
opts = args[0]
}
return createPromise(func(resolve, reject js.Value) {
mu.Lock()
defer mu.Unlock()
if active != nil {
active.Stop()
active = nil
}
h, err := wasmcapture.StartConsole(client, opts)
if err != nil {
reject.Invoke(js.ValueOf(fmt.Sprintf("start capture: %v", err)))
return
}
active = h
console := js.Global().Get("console")
console.Call("log", "[capture] started, call client.stopCapture() to stop")
resolve.Invoke(js.Undefined())
})
})
stopFn = js.FuncOf(func(_ js.Value, _ []js.Value) any {
mu.Lock()
defer mu.Unlock()
if active == nil {
js.Global().Get("console").Call("log", "[capture] no active capture")
return js.Undefined()
}
stats := active.Stop()
active = nil
console := js.Global().Get("console")
console.Call("log", fmt.Sprintf("[capture] stopped: %d packets, %d bytes, %d dropped",
stats.Packets, stats.Bytes, stats.Dropped))
return js.Undefined()
})
return startFn, stopFn
}
// createPromise is a helper to create JavaScript promises // createPromise is a helper to create JavaScript promises
func createPromise(handler func(resolve, reject js.Value)) js.Value { func createPromise(handler func(resolve, reject js.Value)) js.Value {
return js.Global().Get("Promise").New(js.FuncOf(func(_ js.Value, promiseArgs []js.Value) any { return js.Global().Get("Promise").New(js.FuncOf(func(_ js.Value, promiseArgs []js.Value) any {
@@ -521,6 +612,11 @@ func createClientObject(client *netbird.Client) js.Value {
obj["statusDetail"] = createStatusDetailMethod(client) obj["statusDetail"] = createStatusDetailMethod(client)
obj["getSyncResponse"] = createGetSyncResponseMethod(client) obj["getSyncResponse"] = createGetSyncResponseMethod(client)
obj["setLogLevel"] = createSetLogLevelMethod(client) obj["setLogLevel"] = createSetLogLevelMethod(client)
obj["startCapture"] = createStartCaptureMethod(client)
capStart, capStop := captureMethods(client)
obj["capture"] = capStart
obj["stopCapture"] = capStop
return js.ValueOf(obj) return js.ValueOf(obj)
} }

View File

@@ -0,0 +1,211 @@
//go:build js
// Package capture bridges the util/capture package to JavaScript via syscall/js.
package capture
import (
"strings"
"sync"
"syscall/js"
log "github.com/sirupsen/logrus"
netbird "github.com/netbirdio/netbird/client/embed"
"github.com/netbirdio/netbird/util/capture"
)
// Handle holds a running capture session and the embedded client reference
// so it can be stopped later.
type Handle struct {
client *netbird.Client
sess *capture.Session
stopFn js.Func
stopped bool
}
// Stop ends the capture and returns stats.
func (h *Handle) Stop() capture.Stats {
if h.stopped {
return h.sess.Stats()
}
h.stopped = true
h.stopFn.Release()
if err := h.client.SetCapture(nil); err != nil {
log.Debugf("clear capture: %v", err)
}
h.sess.Stop()
return h.sess.Stats()
}
func statsToJS(s capture.Stats) js.Value {
obj := js.Global().Get("Object").Call("create", js.Null())
obj.Set("packets", js.ValueOf(s.Packets))
obj.Set("bytes", js.ValueOf(s.Bytes))
obj.Set("dropped", js.ValueOf(s.Dropped))
return obj
}
// parseOpts extracts filter/verbose/ascii from a JS options value.
func parseOpts(jsOpts js.Value) (filter string, verbose, ascii bool) {
if jsOpts.IsNull() || jsOpts.IsUndefined() {
return
}
if jsOpts.Type() == js.TypeString {
filter = jsOpts.String()
return
}
if jsOpts.Type() != js.TypeObject {
return
}
if f := jsOpts.Get("filter"); !f.IsUndefined() && !f.IsNull() {
filter = f.String()
}
if v := jsOpts.Get("verbose"); !v.IsUndefined() {
verbose = v.Truthy()
}
if a := jsOpts.Get("ascii"); !a.IsUndefined() {
ascii = a.Truthy()
}
return
}
func buildMatcher(filter string) (capture.Matcher, error) {
if filter == "" {
return nil, nil
}
return capture.ParseFilter(filter)
}
// Start creates a capture session and returns a JS interface for streaming text
// output. The returned object exposes:
//
// onpacket(callback) - set callback(string) for each text line
// stop() - stop capture and return stats { packets, bytes, dropped }
//
// Options: { filter: string, verbose: bool, ascii: bool } or just a filter string.
func Start(client *netbird.Client, jsOpts js.Value) (js.Value, error) {
filter, verbose, ascii := parseOpts(jsOpts)
matcher, err := buildMatcher(filter)
if err != nil {
return js.Undefined(), err
}
cb := &jsCallbackWriter{}
sess, err := capture.NewSession(capture.Options{
TextOutput: cb,
Matcher: matcher,
Verbose: verbose,
ASCII: ascii,
})
if err != nil {
return js.Undefined(), err
}
if err := client.SetCapture(sess); err != nil {
sess.Stop()
return js.Undefined(), err
}
handle := &Handle{client: client, sess: sess}
iface := js.Global().Get("Object").Call("create", js.Null())
handle.stopFn = js.FuncOf(func(_ js.Value, _ []js.Value) any {
return statsToJS(handle.Stop())
})
iface.Set("stop", handle.stopFn)
iface.Set("onpacket", js.Undefined())
cb.setInterface(iface)
return iface, nil
}
// StartConsole starts a capture that logs every packet line to console.log.
// Returns a Handle so the caller can stop it later.
func StartConsole(client *netbird.Client, jsOpts js.Value) (*Handle, error) {
filter, verbose, ascii := parseOpts(jsOpts)
matcher, err := buildMatcher(filter)
if err != nil {
return nil, err
}
cb := &jsCallbackWriter{}
sess, err := capture.NewSession(capture.Options{
TextOutput: cb,
Matcher: matcher,
Verbose: verbose,
ASCII: ascii,
})
if err != nil {
return nil, err
}
if err := client.SetCapture(sess); err != nil {
sess.Stop()
return nil, err
}
handle := &Handle{client: client, sess: sess}
handle.stopFn = js.FuncOf(func(_ js.Value, _ []js.Value) any {
return statsToJS(handle.Stop())
})
iface := js.Global().Get("Object").Call("create", js.Null())
console := js.Global().Get("console")
iface.Set("onpacket", console.Get("log").Call("bind", console, js.ValueOf("[capture]")))
cb.setInterface(iface)
return handle, nil
}
// jsCallbackWriter is an io.Writer that buffers text until a newline, then
// invokes the JS onpacket callback with each complete line.
type jsCallbackWriter struct {
mu sync.Mutex
iface js.Value
buf strings.Builder
}
func (w *jsCallbackWriter) setInterface(iface js.Value) {
w.mu.Lock()
defer w.mu.Unlock()
w.iface = iface
}
func (w *jsCallbackWriter) Write(p []byte) (int, error) {
w.mu.Lock()
w.buf.Write(p)
var lines []string
for {
str := w.buf.String()
idx := strings.IndexByte(str, '\n')
if idx < 0 {
break
}
lines = append(lines, str[:idx])
w.buf.Reset()
if idx+1 < len(str) {
w.buf.WriteString(str[idx+1:])
}
}
iface := w.iface
w.mu.Unlock()
if iface.IsUndefined() {
return len(p), nil
}
cb := iface.Get("onpacket")
if cb.IsUndefined() || cb.IsNull() {
return len(p), nil
}
for _, line := range lines {
cb.Invoke(js.ValueOf(line))
}
return len(p), nil
}

View File

@@ -2,7 +2,12 @@ package cmd
import ( import (
"fmt" "fmt"
"os"
"os/signal"
"path/filepath"
"strconv" "strconv"
"strings"
"syscall"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@@ -99,6 +104,27 @@ var debugStopCmd = &cobra.Command{
SilenceUsage: true, SilenceUsage: true,
} }
var debugCaptureCmd = &cobra.Command{
Use: "capture <account-id> [filter expression]",
Short: "Capture packets on a client's WireGuard interface",
Long: `Captures decrypted packets flowing through a client's WireGuard interface.
Default output is human-readable text. Use --pcap or --output for pcap binary.
Filter arguments after the account ID use BPF-like syntax.
Examples:
netbird-proxy debug capture <account-id>
netbird-proxy debug capture <account-id> --duration 1m host 10.0.0.1
netbird-proxy debug capture <account-id> host 10.0.0.1 and tcp port 443
netbird-proxy debug capture <account-id> not port 22
netbird-proxy debug capture <account-id> -o capture.pcap
netbird-proxy debug capture <account-id> --pcap | tcpdump -r - -n
netbird-proxy debug capture <account-id> --pcap | tshark -r -`,
Args: cobra.MinimumNArgs(1),
RunE: runDebugCapture,
SilenceUsage: true,
}
func init() { func init() {
debugCmd.PersistentFlags().StringVar(&debugAddr, "addr", envStringOrDefault("NB_PROXY_DEBUG_ADDRESS", "localhost:8444"), "Debug endpoint address") debugCmd.PersistentFlags().StringVar(&debugAddr, "addr", envStringOrDefault("NB_PROXY_DEBUG_ADDRESS", "localhost:8444"), "Debug endpoint address")
debugCmd.PersistentFlags().BoolVar(&jsonOutput, "json", false, "Output JSON instead of pretty format") debugCmd.PersistentFlags().BoolVar(&jsonOutput, "json", false, "Output JSON instead of pretty format")
@@ -110,6 +136,12 @@ func init() {
debugPingCmd.Flags().StringVar(&pingTimeout, "timeout", "", "Ping timeout (e.g., 10s)") debugPingCmd.Flags().StringVar(&pingTimeout, "timeout", "", "Ping timeout (e.g., 10s)")
debugCaptureCmd.Flags().DurationP("duration", "d", 0, "Capture duration (0 = server default)")
debugCaptureCmd.Flags().Bool("pcap", false, "Force pcap binary output (default when --output is set)")
debugCaptureCmd.Flags().BoolP("verbose", "v", false, "Show seq/ack, TTL, window, total length (text mode)")
debugCaptureCmd.Flags().Bool("ascii", false, "Print payload as ASCII after each packet (text mode)")
debugCaptureCmd.Flags().StringP("output", "o", "", "Write pcap to file instead of stdout")
debugCmd.AddCommand(debugHealthCmd) debugCmd.AddCommand(debugHealthCmd)
debugCmd.AddCommand(debugClientsCmd) debugCmd.AddCommand(debugClientsCmd)
debugCmd.AddCommand(debugStatusCmd) debugCmd.AddCommand(debugStatusCmd)
@@ -119,6 +151,7 @@ func init() {
debugCmd.AddCommand(debugLogCmd) debugCmd.AddCommand(debugLogCmd)
debugCmd.AddCommand(debugStartCmd) debugCmd.AddCommand(debugStartCmd)
debugCmd.AddCommand(debugStopCmd) debugCmd.AddCommand(debugStopCmd)
debugCmd.AddCommand(debugCaptureCmd)
rootCmd.AddCommand(debugCmd) rootCmd.AddCommand(debugCmd)
} }
@@ -171,3 +204,84 @@ func runDebugStart(cmd *cobra.Command, args []string) error {
func runDebugStop(cmd *cobra.Command, args []string) error { func runDebugStop(cmd *cobra.Command, args []string) error {
return getDebugClient(cmd).StopClient(cmd.Context(), args[0]) return getDebugClient(cmd).StopClient(cmd.Context(), args[0])
} }
func runDebugCapture(cmd *cobra.Command, args []string) error {
duration, _ := cmd.Flags().GetDuration("duration")
forcePcap, _ := cmd.Flags().GetBool("pcap")
verbose, _ := cmd.Flags().GetBool("verbose")
ascii, _ := cmd.Flags().GetBool("ascii")
outPath, _ := cmd.Flags().GetString("output")
// Default to text. Use pcap when --pcap is set or --output is given.
wantText := !forcePcap && outPath == ""
var filterExpr string
if len(args) > 1 {
filterExpr = strings.Join(args[1:], " ")
}
ctx, cancel := signal.NotifyContext(cmd.Context(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
out, cleanup, err := captureOutputWriter(cmd, outPath)
if err != nil {
return err
}
defer cleanup()
if wantText {
cmd.PrintErrln("Capturing packets... Press Ctrl+C to stop.")
} else {
cmd.PrintErrln("Capturing packets (pcap)... Press Ctrl+C to stop.")
}
var durationStr string
if duration > 0 {
durationStr = duration.String()
}
err = getDebugClient(cmd).Capture(ctx, debug.CaptureOptions{
AccountID: args[0],
Duration: durationStr,
FilterExpr: filterExpr,
Text: wantText,
Verbose: verbose,
ASCII: ascii,
Output: out,
})
if err != nil {
return err
}
cmd.PrintErrln("\nCapture finished.")
return nil
}
// captureOutputWriter returns the writer and cleanup function for capture output.
func captureOutputWriter(cmd *cobra.Command, outPath string) (out *os.File, cleanup func(), err error) {
if outPath != "" {
f, err := os.CreateTemp(filepath.Dir(outPath), filepath.Base(outPath)+".*.tmp")
if err != nil {
return nil, nil, fmt.Errorf("create output file: %w", err)
}
tmpPath := f.Name()
return f, func() {
if err := f.Close(); err != nil {
cmd.PrintErrf("close output file: %v\n", err)
}
if fi, err := os.Stat(tmpPath); err == nil && fi.Size() > 0 {
if err := os.Rename(tmpPath, outPath); err != nil {
cmd.PrintErrf("rename output file: %v\n", err)
} else {
cmd.PrintErrf("Wrote %s\n", outPath)
}
} else {
os.Remove(tmpPath)
}
}, nil
}
return os.Stdout, func() {
// no cleanup needed for stdout
}, nil
}

View File

@@ -310,6 +310,76 @@ func (c *Client) printError(data map[string]any) {
} }
} }
// CaptureOptions configures a capture request.
type CaptureOptions struct {
AccountID string
Duration string
FilterExpr string
Text bool
Verbose bool
ASCII bool
Output io.Writer
}
// Capture streams a packet capture from the debug endpoint. The response body
// (pcap or text) is written directly to opts.Output until the server closes the
// connection or the context is cancelled.
func (c *Client) Capture(ctx context.Context, opts CaptureOptions) error {
if opts.AccountID == "" {
return fmt.Errorf("account ID is required")
}
if opts.Output == nil {
return fmt.Errorf("output writer is required")
}
params := url.Values{}
if opts.Duration != "" {
params.Set("duration", opts.Duration)
}
if opts.FilterExpr != "" {
params.Set("filter", opts.FilterExpr)
}
if opts.Text {
params.Set("format", "text")
}
if opts.Verbose {
params.Set("verbose", "true")
}
if opts.ASCII {
params.Set("ascii", "true")
}
path := fmt.Sprintf("/debug/clients/%s/capture", url.PathEscape(opts.AccountID))
if len(params) > 0 {
path += "?" + params.Encode()
}
fullURL := c.baseURL + path
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil)
if err != nil {
return fmt.Errorf("create request: %w", err)
}
// Use a separate client without timeout since captures stream for their full duration.
httpClient := &http.Client{}
resp, err := httpClient.Do(req)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("server error (%d): %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
_, err = io.Copy(opts.Output, resp.Body)
if err != nil && ctx.Err() != nil {
return nil
}
return err
}
func (c *Client) fetchAndPrint(ctx context.Context, path string, printer func(map[string]any)) error { func (c *Client) fetchAndPrint(ctx context.Context, path string, printer func(map[string]any)) error {
data, raw, err := c.fetch(ctx, path) data, raw, err := c.fetch(ctx, path)
if err != nil { if err != nil {

View File

@@ -24,6 +24,7 @@ import (
"github.com/netbirdio/netbird/proxy/internal/health" "github.com/netbirdio/netbird/proxy/internal/health"
"github.com/netbirdio/netbird/proxy/internal/roundtrip" "github.com/netbirdio/netbird/proxy/internal/roundtrip"
"github.com/netbirdio/netbird/proxy/internal/types" "github.com/netbirdio/netbird/proxy/internal/types"
"github.com/netbirdio/netbird/util/capture"
"github.com/netbirdio/netbird/version" "github.com/netbirdio/netbird/version"
) )
@@ -174,6 +175,8 @@ func (h *Handler) handleClientRoutes(w http.ResponseWriter, r *http.Request, pat
h.handleClientStart(w, r, accountID) h.handleClientStart(w, r, accountID)
case "stop": case "stop":
h.handleClientStop(w, r, accountID) h.handleClientStop(w, r, accountID)
case "capture":
h.handleCapture(w, r, accountID)
default: default:
return false return false
} }
@@ -632,6 +635,99 @@ func (h *Handler) handleClientStop(w http.ResponseWriter, r *http.Request, accou
}) })
} }
const maxCaptureDuration = 30 * time.Minute
// handleCapture streams a pcap or text packet capture for the given client.
//
// Query params:
//
// duration: capture duration (0 or absent = max, capped at 30m)
// format: "text" for human-readable output (default: pcap)
// filter: BPF-like filter expression (e.g. "host 10.0.0.1 and tcp port 443")
func (h *Handler) handleCapture(w http.ResponseWriter, r *http.Request, accountID types.AccountID) {
client, ok := h.provider.GetClient(accountID)
if !ok {
http.Error(w, "client not found", http.StatusNotFound)
return
}
duration := maxCaptureDuration
if durationStr := r.URL.Query().Get("duration"); durationStr != "" {
d, err := time.ParseDuration(durationStr)
if err != nil {
http.Error(w, "invalid duration: "+err.Error(), http.StatusBadRequest)
return
}
if d < 0 {
http.Error(w, "duration must not be negative", http.StatusBadRequest)
return
}
if d > 0 {
duration = min(d, maxCaptureDuration)
}
}
var matcher capture.Matcher
if expr := r.URL.Query().Get("filter"); expr != "" {
var err error
matcher, err = capture.ParseFilter(expr)
if err != nil {
http.Error(w, "invalid filter: "+err.Error(), http.StatusBadRequest)
return
}
}
wantText := r.URL.Query().Get("format") == "text"
verbose := r.URL.Query().Get("verbose") == "true"
ascii := r.URL.Query().Get("ascii") == "true"
opts := capture.Options{Matcher: matcher, Verbose: verbose, ASCII: ascii}
if wantText {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
opts.TextOutput = w
} else {
w.Header().Set("Content-Type", "application/vnd.tcpdump.pcap")
w.Header().Set("Content-Disposition",
fmt.Sprintf("attachment; filename=capture-%s.pcap", accountID))
opts.Output = w
}
sess, err := capture.NewSession(opts)
if err != nil {
http.Error(w, "create capture session: "+err.Error(), http.StatusInternalServerError)
return
}
defer sess.Stop()
if err := client.SetCapture(sess); err != nil {
http.Error(w, "set capture: "+err.Error(), http.StatusServiceUnavailable)
return
}
defer client.SetCapture(nil) //nolint:errcheck
// Flush headers after setup succeeds so errors above can still set status codes.
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
timer := time.NewTimer(duration)
defer timer.Stop()
select {
case <-r.Context().Done():
case <-timer.C:
}
if err := client.SetCapture(nil); err != nil {
h.logger.Debugf("clear capture: %v", err)
}
sess.Stop()
stats := sess.Stats()
h.logger.Infof("capture for %s finished: %d packets, %d bytes, %d dropped",
accountID, stats.Packets, stats.Bytes, stats.Dropped)
}
func (h *Handler) handleHealth(w http.ResponseWriter, r *http.Request, wantJSON bool) { func (h *Handler) handleHealth(w http.ResponseWriter, r *http.Request, wantJSON bool) {
if !wantJSON { if !wantJSON {
http.Redirect(w, r, "/debug", http.StatusSeeOther) http.Redirect(w, r, "/debug", http.StatusSeeOther)

View File

@@ -0,0 +1,193 @@
package capture
import (
"encoding/binary"
"errors"
"fmt"
"net"
"sync"
"sync/atomic"
log "github.com/sirupsen/logrus"
"golang.org/x/sys/unix"
)
// htons converts a uint16 from host to network (big-endian) byte order.
func htons(v uint16) uint16 {
var buf [2]byte
binary.BigEndian.PutUint16(buf[:], v)
return binary.NativeEndian.Uint16(buf[:])
}
// AFPacketCapture reads raw packets from a network interface using an
// AF_PACKET socket. This is the kernel-mode fallback when FilteredDevice is
// not available (kernel WireGuard). Linux only.
//
// It implements device.PacketCapture so it can be set on a Session, but it
// drives its own read loop rather than being called from FilteredDevice.
// Call Start to begin and Stop to end.
type AFPacketCapture struct {
ifaceName string
sess *Session
fd int
mu sync.Mutex
stopped chan struct{}
started atomic.Bool
closed atomic.Bool
}
// NewAFPacketCapture creates a capture bound to the given interface.
// The session receives packets via Offer.
func NewAFPacketCapture(ifaceName string, sess *Session) *AFPacketCapture {
return &AFPacketCapture{
ifaceName: ifaceName,
sess: sess,
fd: -1,
stopped: make(chan struct{}),
}
}
// Start opens the AF_PACKET socket and begins reading packets.
// Packets are fed to the session via Offer. Returns immediately;
// the read loop runs in a goroutine.
func (c *AFPacketCapture) Start() error {
if c.sess == nil {
return errors.New("nil capture session")
}
if c.started.Load() {
return errors.New("capture already started")
}
iface, err := net.InterfaceByName(c.ifaceName)
if err != nil {
return fmt.Errorf("interface %s: %w", c.ifaceName, err)
}
fd, err := unix.Socket(unix.AF_PACKET, unix.SOCK_DGRAM|unix.SOCK_NONBLOCK|unix.SOCK_CLOEXEC, int(htons(unix.ETH_P_ALL)))
if err != nil {
return fmt.Errorf("create AF_PACKET socket: %w", err)
}
addr := &unix.SockaddrLinklayer{
Protocol: htons(unix.ETH_P_ALL),
Ifindex: iface.Index,
}
if err := unix.Bind(fd, addr); err != nil {
unix.Close(fd)
return fmt.Errorf("bind to %s: %w", c.ifaceName, err)
}
c.mu.Lock()
c.fd = fd
c.mu.Unlock()
c.started.Store(true)
go c.readLoop(fd)
return nil
}
// Stop closes the socket and waits for the read loop to exit. Idempotent.
func (c *AFPacketCapture) Stop() {
if !c.closed.CompareAndSwap(false, true) {
if c.started.Load() {
<-c.stopped
}
return
}
c.mu.Lock()
fd := c.fd
c.fd = -1
c.mu.Unlock()
if fd >= 0 {
unix.Close(fd)
}
if c.started.Load() {
<-c.stopped
}
}
func (c *AFPacketCapture) readLoop(fd int) {
defer close(c.stopped)
buf := make([]byte, 65536)
pollFds := []unix.PollFd{{Fd: int32(fd), Events: unix.POLLIN}}
for {
if c.closed.Load() {
return
}
ok, err := c.pollOnce(pollFds)
if err != nil {
return
}
if !ok {
continue
}
c.recvAndOffer(fd, buf)
}
}
// pollOnce waits for data on the fd. Returns true if data is ready, false for timeout/retry.
// Returns an error to signal the loop should exit.
func (c *AFPacketCapture) pollOnce(pollFds []unix.PollFd) (bool, error) {
n, err := unix.Poll(pollFds, 200)
if err != nil {
if errors.Is(err, unix.EINTR) {
return false, nil
}
if c.closed.Load() {
return false, errors.New("closed")
}
log.Debugf("af_packet poll: %v", err)
return false, err
}
if n == 0 {
return false, nil
}
if pollFds[0].Revents&(unix.POLLERR|unix.POLLHUP|unix.POLLNVAL) != 0 {
return false, errors.New("fd error")
}
return true, nil
}
func (c *AFPacketCapture) recvAndOffer(fd int, buf []byte) {
nr, from, err := unix.Recvfrom(fd, buf, 0)
if err != nil {
if errors.Is(err, unix.EAGAIN) || errors.Is(err, unix.EINTR) {
return
}
if !c.closed.Load() {
log.Debugf("af_packet recvfrom: %v", err)
}
return
}
if nr < 1 {
return
}
ver := buf[0] >> 4
if ver != 4 && ver != 6 {
return
}
// The kernel sets Pkttype on AF_PACKET sockets:
// PACKET_HOST(0) = addressed to us (inbound)
// PACKET_OUTGOING(4) = sent by us (outbound)
outbound := false
if sa, ok := from.(*unix.SockaddrLinklayer); ok {
outbound = sa.Pkttype == unix.PACKET_OUTGOING
}
c.sess.Offer(buf[:nr], outbound)
}
// Offer satisfies device.PacketCapture but is unused: the AFPacketCapture
// drives its own read loop. This exists only so the type signature is
// compatible if someone tries to set it as a PacketCapture.
func (c *AFPacketCapture) Offer([]byte, bool) {
// unused: AFPacketCapture drives its own read loop
}

View File

@@ -0,0 +1,26 @@
//go:build !linux
package capture
import "errors"
// AFPacketCapture is not available on this platform.
type AFPacketCapture struct{}
// NewAFPacketCapture returns nil on non-Linux platforms.
func NewAFPacketCapture(string, *Session) *AFPacketCapture { return nil }
// Start returns an error on non-Linux platforms.
func (c *AFPacketCapture) Start() error {
return errors.New("AF_PACKET capture is only supported on Linux")
}
// Stop is a no-op on non-Linux platforms.
func (c *AFPacketCapture) Stop() {
// no-op on non-Linux platforms
}
// Offer is a no-op on non-Linux platforms.
func (c *AFPacketCapture) Offer([]byte, bool) {
// no-op on non-Linux platforms
}

59
util/capture/capture.go Normal file
View File

@@ -0,0 +1,59 @@
// Package capture provides userspace packet capture in pcap format.
//
// It taps decrypted WireGuard packets flowing through the FilteredDevice and
// writes them as pcap (readable by tcpdump, tshark, Wireshark) or as
// human-readable one-line-per-packet text.
package capture
import "io"
// Direction indicates whether a packet is entering or leaving the host.
type Direction uint8
const (
// Inbound is a packet arriving from the network (FilteredDevice.Write path).
Inbound Direction = iota
// Outbound is a packet leaving the host (FilteredDevice.Read path).
Outbound
)
// String returns "IN" or "OUT".
func (d Direction) String() string {
if d == Outbound {
return "OUT"
}
return "IN"
}
const (
protoICMP = 1
protoTCP = 6
protoUDP = 17
protoICMPv6 = 58
)
// Options configures a capture session.
type Options struct {
// Output receives pcap-formatted data. Nil disables pcap output.
Output io.Writer
// TextOutput receives human-readable packet summaries. Nil disables text output.
TextOutput io.Writer
// Matcher selects which packets to capture. Nil captures all.
// Use ParseFilter("host 10.0.0.1 and tcp") or &Filter{...}.
Matcher Matcher
// Verbose adds seq/ack, TTL, window, total length to text output.
Verbose bool
// ASCII dumps transport payload as printable ASCII after each packet line.
ASCII bool
// SnapLen is the maximum bytes captured per packet. 0 means 65535.
SnapLen uint32
// BufSize is the internal channel buffer size. 0 means 256.
BufSize int
}
// Stats reports capture session counters.
type Stats struct {
Packets int64
Bytes int64
Dropped int64
}

528
util/capture/filter.go Normal file
View File

@@ -0,0 +1,528 @@
package capture
import (
"encoding/binary"
"fmt"
"net/netip"
"strconv"
"strings"
)
// Matcher tests whether a raw packet should be captured.
type Matcher interface {
Match(data []byte) bool
}
// Filter selects packets by flat AND'd criteria. Useful for structured APIs
// (query params, proto fields). Implements Matcher.
type Filter struct {
SrcIP netip.Addr
DstIP netip.Addr
Host netip.Addr
SrcPort uint16
DstPort uint16
Port uint16
Proto uint8
}
// IsEmpty returns true if the filter has no criteria set.
func (f *Filter) IsEmpty() bool {
return !f.SrcIP.IsValid() && !f.DstIP.IsValid() && !f.Host.IsValid() &&
f.SrcPort == 0 && f.DstPort == 0 && f.Port == 0 && f.Proto == 0
}
// Match implements Matcher. All non-zero fields must match (AND).
func (f *Filter) Match(data []byte) bool {
if f.IsEmpty() {
return true
}
info, ok := parsePacketInfo(data)
if !ok {
return false
}
if f.Host.IsValid() && info.srcIP != f.Host && info.dstIP != f.Host {
return false
}
if f.SrcIP.IsValid() && info.srcIP != f.SrcIP {
return false
}
if f.DstIP.IsValid() && info.dstIP != f.DstIP {
return false
}
if f.Proto != 0 && info.proto != f.Proto {
return false
}
if f.Port != 0 && info.srcPort != f.Port && info.dstPort != f.Port {
return false
}
if f.SrcPort != 0 && info.srcPort != f.SrcPort {
return false
}
if f.DstPort != 0 && info.dstPort != f.DstPort {
return false
}
return true
}
// exprNode evaluates a filter condition against pre-parsed packet info.
type exprNode func(info *packetInfo) bool
// exprMatcher wraps an expression tree. Parses the packet once, then walks the tree.
type exprMatcher struct {
root exprNode
}
func (m *exprMatcher) Match(data []byte) bool {
info, ok := parsePacketInfo(data)
if !ok {
return false
}
return m.root(&info)
}
func nodeAnd(a, b exprNode) exprNode {
return func(info *packetInfo) bool { return a(info) && b(info) }
}
func nodeOr(a, b exprNode) exprNode {
return func(info *packetInfo) bool { return a(info) || b(info) }
}
func nodeNot(n exprNode) exprNode {
return func(info *packetInfo) bool { return !n(info) }
}
func nodeHost(addr netip.Addr) exprNode {
return func(info *packetInfo) bool { return info.srcIP == addr || info.dstIP == addr }
}
func nodeSrcHost(addr netip.Addr) exprNode {
return func(info *packetInfo) bool { return info.srcIP == addr }
}
func nodeDstHost(addr netip.Addr) exprNode {
return func(info *packetInfo) bool { return info.dstIP == addr }
}
func nodePort(port uint16) exprNode {
return func(info *packetInfo) bool { return info.srcPort == port || info.dstPort == port }
}
func nodeSrcPort(port uint16) exprNode {
return func(info *packetInfo) bool { return info.srcPort == port }
}
func nodeDstPort(port uint16) exprNode {
return func(info *packetInfo) bool { return info.dstPort == port }
}
func nodeProto(proto uint8) exprNode {
return func(info *packetInfo) bool { return info.proto == proto }
}
func nodeFamily(family uint8) exprNode {
return func(info *packetInfo) bool { return info.family == family }
}
func nodeNet(prefix netip.Prefix) exprNode {
return func(info *packetInfo) bool { return prefix.Contains(info.srcIP) || prefix.Contains(info.dstIP) }
}
func nodeSrcNet(prefix netip.Prefix) exprNode {
return func(info *packetInfo) bool { return prefix.Contains(info.srcIP) }
}
func nodeDstNet(prefix netip.Prefix) exprNode {
return func(info *packetInfo) bool { return prefix.Contains(info.dstIP) }
}
// packetInfo holds parsed header fields for filtering and display.
type packetInfo struct {
family uint8
srcIP netip.Addr
dstIP netip.Addr
proto uint8
srcPort uint16
dstPort uint16
hdrLen int
}
func parsePacketInfo(data []byte) (packetInfo, bool) {
if len(data) < 1 {
return packetInfo{}, false
}
switch data[0] >> 4 {
case 4:
return parseIPv4Info(data)
case 6:
return parseIPv6Info(data)
default:
return packetInfo{}, false
}
}
func parseIPv4Info(data []byte) (packetInfo, bool) {
if len(data) < 20 {
return packetInfo{}, false
}
ihl := int(data[0]&0x0f) * 4
if ihl < 20 || len(data) < ihl {
return packetInfo{}, false
}
info := packetInfo{
family: 4,
srcIP: netip.AddrFrom4([4]byte{data[12], data[13], data[14], data[15]}),
dstIP: netip.AddrFrom4([4]byte{data[16], data[17], data[18], data[19]}),
proto: data[9],
hdrLen: ihl,
}
if (info.proto == protoTCP || info.proto == protoUDP) && len(data) >= ihl+4 {
info.srcPort = binary.BigEndian.Uint16(data[ihl:])
info.dstPort = binary.BigEndian.Uint16(data[ihl+2:])
}
return info, true
}
// parseIPv6Info parses the fixed IPv6 header. It reads the Next Header field
// directly, so packets with extension headers (hop-by-hop, routing, fragment,
// etc.) will report the extension type as the protocol rather than the final
// transport protocol. This is acceptable for a debug capture tool.
func parseIPv6Info(data []byte) (packetInfo, bool) {
if len(data) < 40 {
return packetInfo{}, false
}
var src, dst [16]byte
copy(src[:], data[8:24])
copy(dst[:], data[24:40])
info := packetInfo{
family: 6,
srcIP: netip.AddrFrom16(src),
dstIP: netip.AddrFrom16(dst),
proto: data[6],
hdrLen: 40,
}
if (info.proto == protoTCP || info.proto == protoUDP) && len(data) >= 44 {
info.srcPort = binary.BigEndian.Uint16(data[40:])
info.dstPort = binary.BigEndian.Uint16(data[42:])
}
return info, true
}
// ParseFilter parses a BPF-like filter expression and returns a Matcher.
// Returns nil Matcher for an empty expression (match all).
//
// Grammar (mirrors common tcpdump BPF syntax):
//
// orExpr = andExpr ("or" andExpr)*
// andExpr = unary ("and" unary)*
// unary = "not" unary | "(" orExpr ")" | term
//
// term = "host" IP | "src" target | "dst" target
// | "port" NUM | "net" PREFIX
// | "tcp" | "udp" | "icmp" | "icmp6"
// | "ip" | "ip6" | "proto" NUM
// target = "host" IP | "port" NUM | "net" PREFIX | IP
//
// Examples:
//
// host 10.0.0.1 and tcp port 443
// not port 22
// (host 10.0.0.1 or host 10.0.0.2) and tcp
// ip6 and icmp6
// net 10.0.0.0/24
// src host 10.0.0.1 or dst port 80
func ParseFilter(expr string) (Matcher, error) {
tokens := tokenize(expr)
if len(tokens) == 0 {
return nil, nil //nolint:nilnil // nil Matcher means "match all"
}
p := &parser{tokens: tokens}
node, err := p.parseOr()
if err != nil {
return nil, err
}
if p.pos < len(p.tokens) {
return nil, fmt.Errorf("unexpected token %q at position %d", p.tokens[p.pos], p.pos)
}
return &exprMatcher{root: node}, nil
}
func tokenize(expr string) []string {
expr = strings.TrimSpace(expr)
if expr == "" {
return nil
}
// Split on whitespace but keep parens as separate tokens.
var tokens []string
for _, field := range strings.Fields(expr) {
tokens = append(tokens, splitParens(field)...)
}
return tokens
}
// splitParens splits "(foo)" into "(", "foo", ")".
func splitParens(s string) []string {
var out []string
for strings.HasPrefix(s, "(") {
out = append(out, "(")
s = s[1:]
}
var trail []string
for strings.HasSuffix(s, ")") {
trail = append(trail, ")")
s = s[:len(s)-1]
}
if s != "" {
out = append(out, s)
}
out = append(out, trail...)
return out
}
type parser struct {
tokens []string
pos int
}
func (p *parser) peek() string {
if p.pos >= len(p.tokens) {
return ""
}
return strings.ToLower(p.tokens[p.pos])
}
func (p *parser) next() string {
tok := p.peek()
if tok != "" {
p.pos++
}
return tok
}
func (p *parser) expect(tok string) error {
got := p.next()
if got != tok {
return fmt.Errorf("expected %q, got %q", tok, got)
}
return nil
}
func (p *parser) parseOr() (exprNode, error) {
left, err := p.parseAnd()
if err != nil {
return nil, err
}
for p.peek() == "or" {
p.next()
right, err := p.parseAnd()
if err != nil {
return nil, err
}
left = nodeOr(left, right)
}
return left, nil
}
func (p *parser) parseAnd() (exprNode, error) {
left, err := p.parseUnary()
if err != nil {
return nil, err
}
for {
tok := p.peek()
if tok == "and" {
p.next()
right, err := p.parseUnary()
if err != nil {
return nil, err
}
left = nodeAnd(left, right)
continue
}
// Implicit AND: two atoms without "and" between them.
// Only if the next token starts an atom (not "or", ")", or EOF).
if tok != "" && tok != "or" && tok != ")" {
right, err := p.parseUnary()
if err != nil {
return nil, err
}
left = nodeAnd(left, right)
continue
}
break
}
return left, nil
}
func (p *parser) parseUnary() (exprNode, error) {
switch p.peek() {
case "not":
p.next()
inner, err := p.parseUnary()
if err != nil {
return nil, err
}
return nodeNot(inner), nil
case "(":
p.next()
inner, err := p.parseOr()
if err != nil {
return nil, err
}
if err := p.expect(")"); err != nil {
return nil, fmt.Errorf("unclosed parenthesis")
}
return inner, nil
default:
return p.parseAtom()
}
}
func (p *parser) parseAtom() (exprNode, error) {
tok := p.next()
if tok == "" {
return nil, fmt.Errorf("unexpected end of expression")
}
switch tok {
case "host":
addr, err := p.parseAddr()
if err != nil {
return nil, fmt.Errorf("host: %w", err)
}
return nodeHost(addr), nil
case "port":
port, err := p.parsePort()
if err != nil {
return nil, fmt.Errorf("port: %w", err)
}
return nodePort(port), nil
case "net":
prefix, err := p.parsePrefix()
if err != nil {
return nil, fmt.Errorf("net: %w", err)
}
return nodeNet(prefix), nil
case "src":
return p.parseDirTarget(true)
case "dst":
return p.parseDirTarget(false)
case "tcp":
return nodeProto(protoTCP), nil
case "udp":
return nodeProto(protoUDP), nil
case "icmp":
return nodeProto(protoICMP), nil
case "icmp6":
return nodeProto(protoICMPv6), nil
case "ip":
return nodeFamily(4), nil
case "ip6":
return nodeFamily(6), nil
case "proto":
raw := p.next()
if raw == "" {
return nil, fmt.Errorf("proto: missing number")
}
n, err := strconv.Atoi(raw)
if err != nil || n < 0 || n > 255 {
return nil, fmt.Errorf("proto: invalid number %q", raw)
}
return nodeProto(uint8(n)), nil
default:
return nil, fmt.Errorf("unknown filter keyword %q", tok)
}
}
func (p *parser) parseDirTarget(isSrc bool) (exprNode, error) {
tok := p.peek()
switch tok {
case "host":
p.next()
addr, err := p.parseAddr()
if err != nil {
return nil, err
}
if isSrc {
return nodeSrcHost(addr), nil
}
return nodeDstHost(addr), nil
case "port":
p.next()
port, err := p.parsePort()
if err != nil {
return nil, err
}
if isSrc {
return nodeSrcPort(port), nil
}
return nodeDstPort(port), nil
case "net":
p.next()
prefix, err := p.parsePrefix()
if err != nil {
return nil, err
}
if isSrc {
return nodeSrcNet(prefix), nil
}
return nodeDstNet(prefix), nil
default:
// Try as bare IP: "src 10.0.0.1"
addr, err := p.parseAddr()
if err != nil {
return nil, fmt.Errorf("expected host, port, net, or IP after src/dst, got %q", tok)
}
if isSrc {
return nodeSrcHost(addr), nil
}
return nodeDstHost(addr), nil
}
}
func (p *parser) parseAddr() (netip.Addr, error) {
raw := p.next()
if raw == "" {
return netip.Addr{}, fmt.Errorf("missing IP address")
}
addr, err := netip.ParseAddr(raw)
if err != nil {
return netip.Addr{}, fmt.Errorf("invalid IP %q", raw)
}
return addr.Unmap(), nil
}
func (p *parser) parsePort() (uint16, error) {
raw := p.next()
if raw == "" {
return 0, fmt.Errorf("missing port number")
}
n, err := strconv.Atoi(raw)
if err != nil || n < 1 || n > 65535 {
return 0, fmt.Errorf("invalid port %q", raw)
}
return uint16(n), nil
}
func (p *parser) parsePrefix() (netip.Prefix, error) {
raw := p.next()
if raw == "" {
return netip.Prefix{}, fmt.Errorf("missing network prefix")
}
prefix, err := netip.ParsePrefix(raw)
if err != nil {
return netip.Prefix{}, fmt.Errorf("invalid prefix %q", raw)
}
return prefix, nil
}

263
util/capture/filter_test.go Normal file
View File

@@ -0,0 +1,263 @@
package capture
import (
"net/netip"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// buildIPv4Packet creates a minimal IPv4+TCP/UDP packet for filter testing.
func buildIPv4Packet(t *testing.T, srcIP, dstIP netip.Addr, proto uint8, srcPort, dstPort uint16) []byte {
t.Helper()
hdrLen := 20
pkt := make([]byte, hdrLen+20)
pkt[0] = 0x45
pkt[9] = proto
src := srcIP.As4()
dst := dstIP.As4()
copy(pkt[12:16], src[:])
copy(pkt[16:20], dst[:])
pkt[20] = byte(srcPort >> 8)
pkt[21] = byte(srcPort)
pkt[22] = byte(dstPort >> 8)
pkt[23] = byte(dstPort)
return pkt
}
// buildIPv6Packet creates a minimal IPv6+TCP/UDP packet for filter testing.
func buildIPv6Packet(t *testing.T, srcIP, dstIP netip.Addr, proto uint8, srcPort, dstPort uint16) []byte {
t.Helper()
pkt := make([]byte, 44) // 40 header + 4 ports
pkt[0] = 0x60 // version 6
pkt[6] = proto // next header
src := srcIP.As16()
dst := dstIP.As16()
copy(pkt[8:24], src[:])
copy(pkt[24:40], dst[:])
pkt[40] = byte(srcPort >> 8)
pkt[41] = byte(srcPort)
pkt[42] = byte(dstPort >> 8)
pkt[43] = byte(dstPort)
return pkt
}
// ---- Filter struct tests ----
func TestFilter_Empty(t *testing.T) {
f := Filter{}
assert.True(t, f.IsEmpty())
assert.True(t, f.Match(buildIPv4Packet(t,
netip.MustParseAddr("10.0.0.1"),
netip.MustParseAddr("10.0.0.2"),
protoTCP, 12345, 443)))
}
func TestFilter_Host(t *testing.T) {
f := Filter{Host: netip.MustParseAddr("10.0.0.1")}
assert.True(t, f.Match(buildIPv4Packet(t, netip.MustParseAddr("10.0.0.1"), netip.MustParseAddr("10.0.0.2"), protoTCP, 1234, 80)))
assert.True(t, f.Match(buildIPv4Packet(t, netip.MustParseAddr("10.0.0.2"), netip.MustParseAddr("10.0.0.1"), protoTCP, 1234, 80)))
assert.False(t, f.Match(buildIPv4Packet(t, netip.MustParseAddr("10.0.0.2"), netip.MustParseAddr("10.0.0.3"), protoTCP, 1234, 80)))
}
func TestFilter_InvalidPacket(t *testing.T) {
f := Filter{Host: netip.MustParseAddr("10.0.0.1")}
assert.False(t, f.Match(nil))
assert.False(t, f.Match([]byte{}))
assert.False(t, f.Match([]byte{0x00}))
}
func TestParsePacketInfo_IPv4(t *testing.T) {
pkt := buildIPv4Packet(t, netip.MustParseAddr("192.168.1.1"), netip.MustParseAddr("10.0.0.1"), protoTCP, 54321, 80)
info, ok := parsePacketInfo(pkt)
require.True(t, ok)
assert.Equal(t, uint8(4), info.family)
assert.Equal(t, netip.MustParseAddr("192.168.1.1"), info.srcIP)
assert.Equal(t, netip.MustParseAddr("10.0.0.1"), info.dstIP)
assert.Equal(t, uint8(protoTCP), info.proto)
assert.Equal(t, uint16(54321), info.srcPort)
assert.Equal(t, uint16(80), info.dstPort)
}
func TestParsePacketInfo_IPv6(t *testing.T) {
pkt := buildIPv6Packet(t, netip.MustParseAddr("fd00::1"), netip.MustParseAddr("fd00::2"), protoUDP, 1234, 53)
info, ok := parsePacketInfo(pkt)
require.True(t, ok)
assert.Equal(t, uint8(6), info.family)
assert.Equal(t, netip.MustParseAddr("fd00::1"), info.srcIP)
assert.Equal(t, netip.MustParseAddr("fd00::2"), info.dstIP)
assert.Equal(t, uint8(protoUDP), info.proto)
assert.Equal(t, uint16(1234), info.srcPort)
assert.Equal(t, uint16(53), info.dstPort)
}
// ---- ParseFilter expression tests ----
func matchV4(t *testing.T, m Matcher, srcIP, dstIP string, proto uint8, srcPort, dstPort uint16) bool {
t.Helper()
return m.Match(buildIPv4Packet(t, netip.MustParseAddr(srcIP), netip.MustParseAddr(dstIP), proto, srcPort, dstPort))
}
func matchV6(t *testing.T, m Matcher, srcIP, dstIP string, proto uint8, srcPort, dstPort uint16) bool {
t.Helper()
return m.Match(buildIPv6Packet(t, netip.MustParseAddr(srcIP), netip.MustParseAddr(dstIP), proto, srcPort, dstPort))
}
func TestParseFilter_Empty(t *testing.T) {
m, err := ParseFilter("")
require.NoError(t, err)
assert.Nil(t, m, "empty expression should return nil matcher")
}
func TestParseFilter_Atoms(t *testing.T) {
tests := []struct {
expr string
match bool
}{
{"tcp", true},
{"udp", false},
{"host 10.0.0.1", true},
{"host 10.0.0.99", false},
{"port 443", true},
{"port 80", false},
{"src host 10.0.0.1", true},
{"dst host 10.0.0.2", true},
{"dst host 10.0.0.1", false},
{"src port 12345", true},
{"dst port 443", true},
{"dst port 80", false},
{"proto 6", true},
{"proto 17", false},
}
pkt := buildIPv4Packet(t, netip.MustParseAddr("10.0.0.1"), netip.MustParseAddr("10.0.0.2"), protoTCP, 12345, 443)
for _, tt := range tests {
t.Run(tt.expr, func(t *testing.T) {
m, err := ParseFilter(tt.expr)
require.NoError(t, err)
assert.Equal(t, tt.match, m.Match(pkt))
})
}
}
func TestParseFilter_And(t *testing.T) {
m, err := ParseFilter("host 10.0.0.1 and tcp port 443")
require.NoError(t, err)
assert.True(t, matchV4(t, m, "10.0.0.1", "10.0.0.2", protoTCP, 55555, 443))
assert.False(t, matchV4(t, m, "10.0.0.1", "10.0.0.2", protoUDP, 55555, 443), "wrong proto")
assert.False(t, matchV4(t, m, "10.0.0.3", "10.0.0.2", protoTCP, 55555, 443), "wrong host")
assert.False(t, matchV4(t, m, "10.0.0.1", "10.0.0.2", protoTCP, 55555, 80), "wrong port")
}
func TestParseFilter_ImplicitAnd(t *testing.T) {
// "tcp port 443" = implicit AND between tcp and port 443
m, err := ParseFilter("tcp port 443")
require.NoError(t, err)
assert.True(t, matchV4(t, m, "10.0.0.1", "10.0.0.2", protoTCP, 1, 443))
assert.False(t, matchV4(t, m, "10.0.0.1", "10.0.0.2", protoUDP, 1, 443))
}
func TestParseFilter_Or(t *testing.T) {
m, err := ParseFilter("port 80 or port 443")
require.NoError(t, err)
assert.True(t, matchV4(t, m, "1.2.3.4", "5.6.7.8", protoTCP, 1, 80))
assert.True(t, matchV4(t, m, "1.2.3.4", "5.6.7.8", protoTCP, 1, 443))
assert.False(t, matchV4(t, m, "1.2.3.4", "5.6.7.8", protoTCP, 1, 8080))
}
func TestParseFilter_Not(t *testing.T) {
m, err := ParseFilter("not port 22")
require.NoError(t, err)
assert.True(t, matchV4(t, m, "10.0.0.1", "10.0.0.2", protoTCP, 1, 443))
assert.False(t, matchV4(t, m, "10.0.0.1", "10.0.0.2", protoTCP, 1, 22))
assert.False(t, matchV4(t, m, "10.0.0.1", "10.0.0.2", protoTCP, 22, 80))
}
func TestParseFilter_Parens(t *testing.T) {
m, err := ParseFilter("(port 80 or port 443) and tcp")
require.NoError(t, err)
assert.True(t, matchV4(t, m, "1.2.3.4", "5.6.7.8", protoTCP, 1, 443))
assert.False(t, matchV4(t, m, "1.2.3.4", "5.6.7.8", protoUDP, 1, 443), "wrong proto")
assert.False(t, matchV4(t, m, "1.2.3.4", "5.6.7.8", protoTCP, 1, 8080), "wrong port")
}
func TestParseFilter_Family(t *testing.T) {
mV4, err := ParseFilter("ip")
require.NoError(t, err)
assert.True(t, matchV4(t, mV4, "10.0.0.1", "10.0.0.2", protoTCP, 1, 80))
assert.False(t, matchV6(t, mV4, "fd00::1", "fd00::2", protoTCP, 1, 80))
mV6, err := ParseFilter("ip6")
require.NoError(t, err)
assert.False(t, matchV4(t, mV6, "10.0.0.1", "10.0.0.2", protoTCP, 1, 80))
assert.True(t, matchV6(t, mV6, "fd00::1", "fd00::2", protoTCP, 1, 80))
}
func TestParseFilter_Net(t *testing.T) {
m, err := ParseFilter("net 10.0.0.0/24")
require.NoError(t, err)
assert.True(t, matchV4(t, m, "10.0.0.1", "192.168.1.1", protoTCP, 1, 80), "src in net")
assert.True(t, matchV4(t, m, "192.168.1.1", "10.0.0.200", protoTCP, 1, 80), "dst in net")
assert.False(t, matchV4(t, m, "10.0.1.1", "192.168.1.1", protoTCP, 1, 80), "neither in net")
}
func TestParseFilter_SrcDstNet(t *testing.T) {
m, err := ParseFilter("src net 10.0.0.0/8 and dst net 192.168.0.0/16")
require.NoError(t, err)
assert.True(t, matchV4(t, m, "10.1.2.3", "192.168.1.1", protoTCP, 1, 80))
assert.False(t, matchV4(t, m, "192.168.1.1", "10.1.2.3", protoTCP, 1, 80), "reversed")
}
func TestParseFilter_Complex(t *testing.T) {
// Real-world: capture HTTP(S) traffic to/from specific host, excluding SSH
m, err := ParseFilter("host 10.0.0.1 and (port 80 or port 443) and not port 22")
require.NoError(t, err)
assert.True(t, matchV4(t, m, "10.0.0.1", "10.0.0.2", protoTCP, 55555, 443))
assert.True(t, matchV4(t, m, "10.0.0.2", "10.0.0.1", protoTCP, 55555, 80))
assert.False(t, matchV4(t, m, "10.0.0.1", "10.0.0.2", protoTCP, 22, 443), "port 22 excluded")
assert.False(t, matchV4(t, m, "10.0.0.3", "10.0.0.2", protoTCP, 55555, 443), "wrong host")
}
func TestParseFilter_IPv6Combined(t *testing.T) {
m, err := ParseFilter("ip6 and icmp6")
require.NoError(t, err)
assert.True(t, matchV6(t, m, "fd00::1", "fd00::2", protoICMPv6, 0, 0))
assert.False(t, matchV4(t, m, "10.0.0.1", "10.0.0.2", protoICMP, 0, 0), "wrong family")
assert.False(t, matchV6(t, m, "fd00::1", "fd00::2", protoTCP, 1, 80), "wrong proto")
}
func TestParseFilter_CaseInsensitive(t *testing.T) {
m, err := ParseFilter("HOST 10.0.0.1 AND TCP PORT 443")
require.NoError(t, err)
assert.True(t, matchV4(t, m, "10.0.0.1", "10.0.0.2", protoTCP, 1, 443))
}
func TestParseFilter_Errors(t *testing.T) {
bad := []string{
"badkeyword",
"host",
"port abc",
"port 99999",
"net invalid",
"(",
"(port 80",
"not",
"src",
}
for _, expr := range bad {
t.Run(expr, func(t *testing.T) {
_, err := ParseFilter(expr)
assert.Error(t, err, "should fail for %q", expr)
})
}
}

85
util/capture/pcap.go Normal file
View File

@@ -0,0 +1,85 @@
package capture
import (
"encoding/binary"
"io"
"time"
)
const (
pcapMagic = 0xa1b2c3d4
pcapVersionMaj = 2
pcapVersionMin = 4
// linkTypeRaw is LINKTYPE_RAW: raw IPv4/IPv6 packets without link-layer header.
linkTypeRaw = 101
defaultSnapLen = 65535
)
// PcapWriter writes packets in pcap format to an underlying writer.
// The global header is written lazily on the first WritePacket call so that
// the writer can be used with unbuffered io.Pipes without deadlocking.
// It is not safe for concurrent use; callers must serialize access.
type PcapWriter struct {
w io.Writer
snapLen uint32
headerWritten bool
}
// NewPcapWriter creates a pcap writer. The global header is deferred until the
// first WritePacket call.
func NewPcapWriter(w io.Writer, snapLen uint32) *PcapWriter {
if snapLen == 0 {
snapLen = defaultSnapLen
}
return &PcapWriter{w: w, snapLen: snapLen}
}
// writeGlobalHeader writes the 24-byte pcap file header.
func (pw *PcapWriter) writeGlobalHeader() error {
var hdr [24]byte
binary.LittleEndian.PutUint32(hdr[0:4], pcapMagic)
binary.LittleEndian.PutUint16(hdr[4:6], pcapVersionMaj)
binary.LittleEndian.PutUint16(hdr[6:8], pcapVersionMin)
binary.LittleEndian.PutUint32(hdr[16:20], pw.snapLen)
binary.LittleEndian.PutUint32(hdr[20:24], linkTypeRaw)
_, err := pw.w.Write(hdr[:])
return err
}
// WriteHeader writes the pcap global header. Safe to call multiple times.
func (pw *PcapWriter) WriteHeader() error {
if pw.headerWritten {
return nil
}
if err := pw.writeGlobalHeader(); err != nil {
return err
}
pw.headerWritten = true
return nil
}
// WritePacket writes a single packet record, preceded by the global header
// on the first call.
func (pw *PcapWriter) WritePacket(ts time.Time, data []byte) error {
if err := pw.WriteHeader(); err != nil {
return err
}
origLen := uint32(len(data))
if origLen > pw.snapLen {
data = data[:pw.snapLen]
}
var hdr [16]byte
binary.LittleEndian.PutUint32(hdr[0:4], uint32(ts.Unix()))
binary.LittleEndian.PutUint32(hdr[4:8], uint32(ts.Nanosecond()/1000))
binary.LittleEndian.PutUint32(hdr[8:12], uint32(len(data)))
binary.LittleEndian.PutUint32(hdr[12:16], origLen)
if _, err := pw.w.Write(hdr[:]); err != nil {
return err
}
_, err := pw.w.Write(data)
return err
}

68
util/capture/pcap_test.go Normal file
View File

@@ -0,0 +1,68 @@
package capture
import (
"bytes"
"encoding/binary"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPcapWriter_GlobalHeader(t *testing.T) {
var buf bytes.Buffer
pw := NewPcapWriter(&buf, 0)
// Header is lazy, so write a dummy packet to trigger it.
err := pw.WritePacket(time.Now(), []byte{0x45, 0, 0, 20, 0, 0, 0, 0, 64, 1, 0, 0, 10, 0, 0, 1, 10, 0, 0, 2})
require.NoError(t, err)
data := buf.Bytes()
require.GreaterOrEqual(t, len(data), 24, "should contain global header")
assert.Equal(t, uint32(pcapMagic), binary.LittleEndian.Uint32(data[0:4]), "magic number")
assert.Equal(t, uint16(pcapVersionMaj), binary.LittleEndian.Uint16(data[4:6]), "version major")
assert.Equal(t, uint16(pcapVersionMin), binary.LittleEndian.Uint16(data[6:8]), "version minor")
assert.Equal(t, uint32(defaultSnapLen), binary.LittleEndian.Uint32(data[16:20]), "snap length")
assert.Equal(t, uint32(linkTypeRaw), binary.LittleEndian.Uint32(data[20:24]), "link type")
}
func TestPcapWriter_WritePacket(t *testing.T) {
var buf bytes.Buffer
pw := NewPcapWriter(&buf, 100)
ts := time.Date(2025, 6, 15, 12, 30, 45, 123456000, time.UTC)
payload := make([]byte, 50)
for i := range payload {
payload[i] = byte(i)
}
err := pw.WritePacket(ts, payload)
require.NoError(t, err)
data := buf.Bytes()[24:] // skip global header
require.Len(t, data, 16+50, "packet header + payload")
assert.Equal(t, uint32(ts.Unix()), binary.LittleEndian.Uint32(data[0:4]), "timestamp seconds")
assert.Equal(t, uint32(123456), binary.LittleEndian.Uint32(data[4:8]), "timestamp microseconds")
assert.Equal(t, uint32(50), binary.LittleEndian.Uint32(data[8:12]), "included length")
assert.Equal(t, uint32(50), binary.LittleEndian.Uint32(data[12:16]), "original length")
assert.Equal(t, payload, data[16:], "packet data")
}
func TestPcapWriter_SnapLen(t *testing.T) {
var buf bytes.Buffer
pw := NewPcapWriter(&buf, 10)
ts := time.Now()
payload := make([]byte, 50)
err := pw.WritePacket(ts, payload)
require.NoError(t, err)
data := buf.Bytes()[24:]
assert.Equal(t, uint32(10), binary.LittleEndian.Uint32(data[8:12]), "included length should be truncated")
assert.Equal(t, uint32(50), binary.LittleEndian.Uint32(data[12:16]), "original length preserved")
assert.Len(t, data[16:], 10, "only snap_len bytes written")
}

213
util/capture/session.go Normal file
View File

@@ -0,0 +1,213 @@
package capture
import (
"fmt"
"sync"
"sync/atomic"
"time"
)
const defaultBufSize = 256
type packetEntry struct {
ts time.Time
data []byte
dir Direction
}
// Session manages an active packet capture. Packets are offered via Offer,
// buffered in a channel, and written to configured sinks by a background
// goroutine. This keeps the hot path (FilteredDevice.Read/Write) non-blocking.
//
// The caller must call Stop when done to flush remaining packets and release
// resources.
type Session struct {
pcapW *PcapWriter
textW *TextWriter
matcher Matcher
snapLen uint32
flushFn func()
ch chan packetEntry
done chan struct{}
stopped chan struct{}
closeOnce sync.Once
closed atomic.Bool
packets atomic.Int64
bytes atomic.Int64
dropped atomic.Int64
started time.Time
}
// NewSession creates and starts a capture session. At least one of
// Options.Output or Options.TextOutput must be non-nil.
func NewSession(opts Options) (*Session, error) {
if opts.Output == nil && opts.TextOutput == nil {
return nil, fmt.Errorf("at least one output sink required")
}
snapLen := opts.SnapLen
if snapLen == 0 {
snapLen = defaultSnapLen
}
bufSize := opts.BufSize
if bufSize <= 0 {
bufSize = defaultBufSize
}
s := &Session{
matcher: opts.Matcher,
snapLen: snapLen,
ch: make(chan packetEntry, bufSize),
done: make(chan struct{}),
stopped: make(chan struct{}),
started: time.Now(),
}
if opts.Output != nil {
s.pcapW = NewPcapWriter(opts.Output, snapLen)
}
if opts.TextOutput != nil {
s.textW = NewTextWriter(opts.TextOutput, opts.Verbose, opts.ASCII)
}
s.flushFn = buildFlushFn(opts.Output, opts.TextOutput)
go s.run()
return s, nil
}
// Offer submits a packet for capture. It returns immediately and never blocks
// the caller. If the internal buffer is full the packet is dropped silently.
//
// outbound should be true for packets leaving the host (FilteredDevice.Read
// path) and false for packets arriving (FilteredDevice.Write path).
//
// Offer satisfies the device.PacketCapture interface.
func (s *Session) Offer(data []byte, outbound bool) {
if s.closed.Load() {
return
}
if s.matcher != nil && !s.matcher.Match(data) {
return
}
captureLen := len(data)
if s.snapLen > 0 && uint32(captureLen) > s.snapLen {
captureLen = int(s.snapLen)
}
copied := make([]byte, captureLen)
copy(copied, data)
dir := Inbound
if outbound {
dir = Outbound
}
select {
case s.ch <- packetEntry{ts: time.Now(), data: copied, dir: dir}:
s.packets.Add(1)
s.bytes.Add(int64(len(data)))
default:
s.dropped.Add(1)
}
}
// Stop signals the session to stop accepting packets, drains any buffered
// packets to the sinks, and waits for the writer goroutine to exit.
// It is safe to call multiple times.
func (s *Session) Stop() {
s.closeOnce.Do(func() {
s.closed.Store(true)
close(s.done)
})
<-s.stopped
}
// Done returns a channel that is closed when the session's writer goroutine
// has fully exited and all buffered packets have been flushed.
func (s *Session) Done() <-chan struct{} {
return s.stopped
}
// Stats returns current capture counters.
func (s *Session) Stats() Stats {
return Stats{
Packets: s.packets.Load(),
Bytes: s.bytes.Load(),
Dropped: s.dropped.Load(),
}
}
func (s *Session) run() {
defer close(s.stopped)
for {
select {
case pkt := <-s.ch:
s.write(pkt)
case <-s.done:
s.drain()
return
}
}
}
func (s *Session) drain() {
for {
select {
case pkt := <-s.ch:
s.write(pkt)
default:
return
}
}
}
func (s *Session) write(pkt packetEntry) {
if s.pcapW != nil {
// Best-effort: if the writer fails (broken pipe etc.), discard silently.
_ = s.pcapW.WritePacket(pkt.ts, pkt.data)
}
if s.textW != nil {
_ = s.textW.WritePacket(pkt.ts, pkt.data, pkt.dir)
}
s.flushFn()
}
// buildFlushFn returns a function that flushes all writers that support it.
// This covers http.Flusher and similar streaming writers.
func buildFlushFn(writers ...any) func() {
type flusher interface {
Flush()
}
var fns []func()
for _, w := range writers {
if w == nil {
continue
}
if f, ok := w.(flusher); ok {
fns = append(fns, f.Flush)
}
}
switch len(fns) {
case 0:
return func() {
// no writers to flush
}
case 1:
return fns[0]
default:
return func() {
for _, fn := range fns {
fn()
}
}
}
}

View File

@@ -0,0 +1,144 @@
package capture
import (
"bytes"
"encoding/binary"
"net/netip"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSession_PcapOutput(t *testing.T) {
var buf bytes.Buffer
sess, err := NewSession(Options{
Output: &buf,
BufSize: 16,
})
require.NoError(t, err)
pkt := buildIPv4Packet(t,
netip.MustParseAddr("10.0.0.1"),
netip.MustParseAddr("10.0.0.2"),
protoTCP, 12345, 443)
sess.Offer(pkt, true)
sess.Stop()
data := buf.Bytes()
require.Greater(t, len(data), 24, "should have global header + at least one packet")
// Verify global header
assert.Equal(t, uint32(pcapMagic), binary.LittleEndian.Uint32(data[0:4]))
assert.Equal(t, uint32(linkTypeRaw), binary.LittleEndian.Uint32(data[20:24]))
// Verify packet record
pktData := data[24:]
inclLen := binary.LittleEndian.Uint32(pktData[8:12])
assert.Equal(t, uint32(len(pkt)), inclLen)
stats := sess.Stats()
assert.Equal(t, int64(1), stats.Packets)
assert.Equal(t, int64(len(pkt)), stats.Bytes)
assert.Equal(t, int64(0), stats.Dropped)
}
func TestSession_TextOutput(t *testing.T) {
var buf bytes.Buffer
sess, err := NewSession(Options{
TextOutput: &buf,
BufSize: 16,
})
require.NoError(t, err)
pkt := buildIPv4Packet(t,
netip.MustParseAddr("10.0.0.1"),
netip.MustParseAddr("10.0.0.2"),
protoTCP, 12345, 443)
sess.Offer(pkt, false)
sess.Stop()
output := buf.String()
assert.Contains(t, output, "TCP")
assert.Contains(t, output, "10.0.0.1")
assert.Contains(t, output, "10.0.0.2")
assert.Contains(t, output, "443")
assert.Contains(t, output, "[IN TCP]")
}
func TestSession_Filter(t *testing.T) {
var buf bytes.Buffer
sess, err := NewSession(Options{
Output: &buf,
Matcher: &Filter{Port: 443},
})
require.NoError(t, err)
pktMatch := buildIPv4Packet(t,
netip.MustParseAddr("10.0.0.1"),
netip.MustParseAddr("10.0.0.2"),
protoTCP, 12345, 443)
pktNoMatch := buildIPv4Packet(t,
netip.MustParseAddr("10.0.0.1"),
netip.MustParseAddr("10.0.0.2"),
protoTCP, 12345, 80)
sess.Offer(pktMatch, true)
sess.Offer(pktNoMatch, true)
sess.Stop()
stats := sess.Stats()
assert.Equal(t, int64(1), stats.Packets, "only matching packet should be captured")
}
func TestSession_StopIdempotent(t *testing.T) {
var buf bytes.Buffer
sess, err := NewSession(Options{Output: &buf})
require.NoError(t, err)
sess.Stop()
sess.Stop() // should not panic or deadlock
}
func TestSession_OfferAfterStop(t *testing.T) {
var buf bytes.Buffer
sess, err := NewSession(Options{Output: &buf})
require.NoError(t, err)
sess.Stop()
pkt := buildIPv4Packet(t,
netip.MustParseAddr("10.0.0.1"),
netip.MustParseAddr("10.0.0.2"),
protoTCP, 12345, 443)
sess.Offer(pkt, true) // should not panic
assert.Equal(t, int64(0), sess.Stats().Packets)
}
func TestSession_Done(t *testing.T) {
var buf bytes.Buffer
sess, err := NewSession(Options{Output: &buf})
require.NoError(t, err)
select {
case <-sess.Done():
t.Fatal("Done should not be closed before Stop")
default:
}
sess.Stop()
select {
case <-sess.Done():
case <-time.After(time.Second):
t.Fatal("Done should be closed after Stop")
}
}
func TestSession_RequiresOutput(t *testing.T) {
_, err := NewSession(Options{})
assert.Error(t, err)
}

638
util/capture/text.go Normal file
View File

@@ -0,0 +1,638 @@
package capture
import (
"encoding/binary"
"fmt"
"io"
"net/netip"
"strings"
"time"
"github.com/google/gopacket"
"github.com/google/gopacket/layers"
)
// TextWriter writes human-readable one-line-per-packet summaries.
// It is not safe for concurrent use; callers must serialize access.
type TextWriter struct {
w io.Writer
verbose bool
ascii bool
flows map[dirKey]uint32
}
type dirKey struct {
src netip.AddrPort
dst netip.AddrPort
}
// NewTextWriter creates a text formatter that writes to w.
func NewTextWriter(w io.Writer, verbose, ascii bool) *TextWriter {
return &TextWriter{
w: w,
verbose: verbose,
ascii: ascii,
flows: make(map[dirKey]uint32),
}
}
// tag formats the fixed-width "[DIR PROTO]" prefix with right-aligned protocol.
func tag(dir Direction, proto string) string {
return fmt.Sprintf("[%-3s %4s]", dir, proto)
}
// WritePacket formats and writes a single packet line.
func (tw *TextWriter) WritePacket(ts time.Time, data []byte, dir Direction) error {
ts = ts.Local()
info, ok := parsePacketInfo(data)
if !ok {
_, err := fmt.Fprintf(tw.w, "%s [%-3s ?] ??? len=%d\n",
ts.Format("15:04:05.000000"), dir, len(data))
return err
}
timeStr := ts.Format("15:04:05.000000")
var err error
switch info.proto {
case protoTCP:
err = tw.writeTCP(timeStr, dir, &info, data)
case protoUDP:
err = tw.writeUDP(timeStr, dir, &info, data)
case protoICMP:
err = tw.writeICMPv4(timeStr, dir, &info, data)
case protoICMPv6:
err = tw.writeICMPv6(timeStr, dir, &info, data)
default:
var verbose string
if tw.verbose {
verbose = tw.verboseIP(data, info.family)
}
_, err = fmt.Fprintf(tw.w, "%s %s %s > %s length %d%s\n",
timeStr, tag(dir, fmt.Sprintf("P%d", info.proto)),
info.srcIP, info.dstIP, len(data)-info.hdrLen, verbose)
}
return err
}
func (tw *TextWriter) writeTCP(timeStr string, dir Direction, info *packetInfo, data []byte) error {
tcp := &layers.TCP{}
if err := tcp.DecodeFromBytes(data[info.hdrLen:], gopacket.NilDecodeFeedback); err != nil {
return tw.writeFallback(timeStr, dir, "TCP", info, data)
}
flags := tcpFlagsStr(tcp)
plen := len(tcp.Payload)
// Protocol annotation
var annotation string
if plen > 0 {
annotation = annotatePayload(tcp.Payload)
}
if !tw.verbose {
_, err := fmt.Fprintf(tw.w, "%s %s %s:%d > %s:%d [%s] length %d%s\n",
timeStr, tag(dir, "TCP"),
info.srcIP, info.srcPort, info.dstIP, info.dstPort,
flags, plen, annotation)
if err != nil {
return err
}
if tw.ascii && plen > 0 {
return tw.writeASCII(tcp.Payload)
}
return nil
}
relSeq, relAck := tw.relativeSeqAck(info, tcp.Seq, tcp.Ack)
var seqStr string
if plen > 0 {
seqStr = fmt.Sprintf(", seq %d:%d", relSeq, relSeq+uint32(plen))
} else {
seqStr = fmt.Sprintf(", seq %d", relSeq)
}
var ackStr string
if tcp.ACK {
ackStr = fmt.Sprintf(", ack %d", relAck)
}
var opts string
if s := formatTCPOptions(tcp.Options); s != "" {
opts = ", options [" + s + "]"
}
verbose := tw.verboseIP(data, info.family)
_, err := fmt.Fprintf(tw.w, "%s %s %s:%d > %s:%d [%s]%s%s, win %d%s, length %d%s%s\n",
timeStr, tag(dir, "TCP"),
info.srcIP, info.srcPort, info.dstIP, info.dstPort,
flags, seqStr, ackStr, tcp.Window, opts, plen, annotation, verbose)
if err != nil {
return err
}
if tw.ascii && plen > 0 {
return tw.writeASCII(tcp.Payload)
}
return nil
}
func (tw *TextWriter) writeUDP(timeStr string, dir Direction, info *packetInfo, data []byte) error {
udp := &layers.UDP{}
if err := udp.DecodeFromBytes(data[info.hdrLen:], gopacket.NilDecodeFeedback); err != nil {
return tw.writeFallback(timeStr, dir, "UDP", info, data)
}
plen := len(udp.Payload)
// DNS replaces the entire line format
if plen > 0 && isDNSPort(info.srcPort, info.dstPort) {
if s := formatDNSPayload(udp.Payload); s != "" {
var verbose string
if tw.verbose {
verbose = tw.verboseIP(data, info.family)
}
_, err := fmt.Fprintf(tw.w, "%s %s %s:%d > %s:%d %s%s\n",
timeStr, tag(dir, "UDP"),
info.srcIP, info.srcPort, info.dstIP, info.dstPort,
s, verbose)
return err
}
}
var verbose string
if tw.verbose {
verbose = tw.verboseIP(data, info.family)
}
_, err := fmt.Fprintf(tw.w, "%s %s %s:%d > %s:%d length %d%s\n",
timeStr, tag(dir, "UDP"),
info.srcIP, info.srcPort, info.dstIP, info.dstPort,
plen, verbose)
if err != nil {
return err
}
if tw.ascii && plen > 0 {
return tw.writeASCII(udp.Payload)
}
return nil
}
func (tw *TextWriter) writeICMPv4(timeStr string, dir Direction, info *packetInfo, data []byte) error {
icmp := &layers.ICMPv4{}
if err := icmp.DecodeFromBytes(data[info.hdrLen:], gopacket.NilDecodeFeedback); err != nil {
return tw.writeFallback(timeStr, dir, "ICMP", info, data)
}
var detail string
if icmp.TypeCode.Type() == layers.ICMPv4TypeEchoRequest || icmp.TypeCode.Type() == layers.ICMPv4TypeEchoReply {
detail = fmt.Sprintf("%s, id %d, seq %d", icmp.TypeCode.String(), icmp.Id, icmp.Seq)
} else {
detail = icmp.TypeCode.String()
}
var verbose string
if tw.verbose {
verbose = tw.verboseIP(data, info.family)
}
_, err := fmt.Fprintf(tw.w, "%s %s %s > %s %s, length %d%s\n",
timeStr, tag(dir, "ICMP"), info.srcIP, info.dstIP, detail, len(data)-info.hdrLen, verbose)
return err
}
func (tw *TextWriter) writeICMPv6(timeStr string, dir Direction, info *packetInfo, data []byte) error {
icmp := &layers.ICMPv6{}
if err := icmp.DecodeFromBytes(data[info.hdrLen:], gopacket.NilDecodeFeedback); err != nil {
return tw.writeFallback(timeStr, dir, "ICMP", info, data)
}
var verbose string
if tw.verbose {
verbose = tw.verboseIP(data, info.family)
}
_, err := fmt.Fprintf(tw.w, "%s %s %s > %s %s, length %d%s\n",
timeStr, tag(dir, "ICMP"), info.srcIP, info.dstIP, icmp.TypeCode.String(), len(data)-info.hdrLen, verbose)
return err
}
func (tw *TextWriter) writeFallback(timeStr string, dir Direction, proto string, info *packetInfo, data []byte) error {
_, err := fmt.Fprintf(tw.w, "%s %s %s:%d > %s:%d length %d\n",
timeStr, tag(dir, proto),
info.srcIP, info.srcPort, info.dstIP, info.dstPort,
len(data)-info.hdrLen)
return err
}
func (tw *TextWriter) verboseIP(data []byte, family uint8) string {
return fmt.Sprintf(", ttl %d, id %d, iplen %d",
ipTTL(data, family), ipID(data, family), len(data))
}
// relativeSeqAck returns seq/ack relative to the first seen value per direction.
func (tw *TextWriter) relativeSeqAck(info *packetInfo, seq, ack uint32) (relSeq, relAck uint32) {
fwd := dirKey{
src: netip.AddrPortFrom(info.srcIP, info.srcPort),
dst: netip.AddrPortFrom(info.dstIP, info.dstPort),
}
rev := dirKey{
src: netip.AddrPortFrom(info.dstIP, info.dstPort),
dst: netip.AddrPortFrom(info.srcIP, info.srcPort),
}
if isn, ok := tw.flows[fwd]; ok {
relSeq = seq - isn
} else {
tw.flows[fwd] = seq
}
if isn, ok := tw.flows[rev]; ok {
relAck = ack - isn
} else {
relAck = ack
}
return relSeq, relAck
}
// writeASCII prints payload bytes as printable ASCII.
func (tw *TextWriter) writeASCII(payload []byte) error {
if len(payload) == 0 {
return nil
}
buf := make([]byte, len(payload))
for i, b := range payload {
switch {
case b >= 0x20 && b < 0x7f:
buf[i] = b
case b == '\n' || b == '\r' || b == '\t':
buf[i] = b
default:
buf[i] = '.'
}
}
_, err := fmt.Fprintf(tw.w, "%s\n", buf)
return err
}
// --- TCP helpers ---
func ipTTL(data []byte, family uint8) uint8 {
if family == 4 && len(data) > 8 {
return data[8]
}
if family == 6 && len(data) > 7 {
return data[7]
}
return 0
}
func ipID(data []byte, family uint8) uint16 {
if family == 4 && len(data) >= 6 {
return binary.BigEndian.Uint16(data[4:6])
}
return 0
}
func tcpFlagsStr(tcp *layers.TCP) string {
var buf [6]byte
n := 0
if tcp.SYN {
buf[n] = 'S'
n++
}
if tcp.FIN {
buf[n] = 'F'
n++
}
if tcp.RST {
buf[n] = 'R'
n++
}
if tcp.PSH {
buf[n] = 'P'
n++
}
if tcp.ACK {
buf[n] = '.'
n++
}
if tcp.URG {
buf[n] = 'U'
n++
}
if n == 0 {
return "none"
}
return string(buf[:n])
}
func formatTCPOptions(opts []layers.TCPOption) string {
var parts []string
for _, opt := range opts {
switch opt.OptionType {
case layers.TCPOptionKindEndList:
return strings.Join(parts, ",")
case layers.TCPOptionKindNop:
parts = append(parts, "nop")
case layers.TCPOptionKindMSS:
if len(opt.OptionData) == 2 {
parts = append(parts, fmt.Sprintf("mss %d", binary.BigEndian.Uint16(opt.OptionData)))
}
case layers.TCPOptionKindWindowScale:
if len(opt.OptionData) == 1 {
parts = append(parts, fmt.Sprintf("wscale %d", opt.OptionData[0]))
}
case layers.TCPOptionKindSACKPermitted:
parts = append(parts, "sackOK")
case layers.TCPOptionKindSACK:
blocks := len(opt.OptionData) / 8
parts = append(parts, fmt.Sprintf("sack %d", blocks))
case layers.TCPOptionKindTimestamps:
if len(opt.OptionData) == 8 {
tsval := binary.BigEndian.Uint32(opt.OptionData[0:4])
tsecr := binary.BigEndian.Uint32(opt.OptionData[4:8])
parts = append(parts, fmt.Sprintf("TS val %d ecr %d", tsval, tsecr))
}
}
}
return strings.Join(parts, ",")
}
// --- Protocol annotation ---
// annotatePayload returns a protocol annotation string for known application protocols.
func annotatePayload(payload []byte) string {
if len(payload) < 4 {
return ""
}
s := string(payload)
// SSH banner: "SSH-2.0-OpenSSH_9.6\r\n"
if strings.HasPrefix(s, "SSH-") {
if end := strings.IndexByte(s, '\r'); end > 0 && end < 256 {
return ": " + s[:end]
}
}
// TLS records
if ann := annotateTLS(payload); ann != "" {
return ": " + ann
}
// HTTP request or response
for _, method := range [...]string{"GET ", "POST ", "PUT ", "DELETE ", "HEAD ", "PATCH ", "OPTIONS ", "CONNECT "} {
if strings.HasPrefix(s, method) {
if end := strings.IndexByte(s, '\r'); end > 0 && end < 200 {
return ": " + s[:end]
}
}
}
if strings.HasPrefix(s, "HTTP/") {
if end := strings.IndexByte(s, '\r'); end > 0 && end < 200 {
return ": " + s[:end]
}
}
return ""
}
// annotateTLS returns a description for TLS handshake and alert records.
func annotateTLS(data []byte) string {
if len(data) < 6 {
return ""
}
switch data[0] {
case 0x16:
return annotateTLSHandshake(data)
case 0x15:
return annotateTLSAlert(data)
}
return ""
}
func annotateTLSHandshake(data []byte) string {
if len(data) < 10 {
return ""
}
switch data[5] {
case 0x01:
if sni := extractSNI(data); sni != "" {
return "TLS ClientHello SNI=" + sni
}
return "TLS ClientHello"
case 0x02:
return "TLS ServerHello"
}
return ""
}
func annotateTLSAlert(data []byte) string {
if len(data) < 7 {
return ""
}
severity := "warning"
if data[5] == 2 {
severity = "fatal"
}
return fmt.Sprintf("TLS Alert %s %s", severity, tlsAlertDesc(data[6]))
}
func tlsAlertDesc(code byte) string {
switch code {
case 0:
return "close_notify"
case 10:
return "unexpected_message"
case 40:
return "handshake_failure"
case 42:
return "bad_certificate"
case 43:
return "unsupported_certificate"
case 44:
return "certificate_revoked"
case 45:
return "certificate_expired"
case 48:
return "unknown_ca"
case 49:
return "access_denied"
case 50:
return "decode_error"
case 70:
return "protocol_version"
case 80:
return "internal_error"
case 86:
return "inappropriate_fallback"
case 90:
return "user_canceled"
case 112:
return "unrecognized_name"
default:
return fmt.Sprintf("alert(%d)", code)
}
}
// extractSNI parses a TLS ClientHello and returns the SNI server name.
func extractSNI(data []byte) string {
if len(data) < 6 || data[0] != 0x16 {
return ""
}
recordLen := int(binary.BigEndian.Uint16(data[3:5]))
handshake := data[5:]
if len(handshake) > recordLen {
handshake = handshake[:recordLen]
}
if len(handshake) < 4 || handshake[0] != 0x01 {
return ""
}
hsLen := int(handshake[1])<<16 | int(handshake[2])<<8 | int(handshake[3])
body := handshake[4:]
if len(body) > hsLen {
body = body[:hsLen]
}
extPos := clientHelloExtensionsOffset(body)
if extPos < 0 {
return ""
}
return findSNIExtension(body, extPos)
}
// clientHelloExtensionsOffset returns the byte offset where extensions begin
// within the ClientHello body, or -1 if the body is too short.
func clientHelloExtensionsOffset(body []byte) int {
if len(body) < 38 {
return -1
}
pos := 34
if pos >= len(body) {
return -1
}
pos += 1 + int(body[pos]) // session ID
if pos+2 > len(body) {
return -1
}
pos += 2 + int(binary.BigEndian.Uint16(body[pos:pos+2])) // cipher suites
if pos >= len(body) {
return -1
}
pos += 1 + int(body[pos]) // compression methods
if pos+2 > len(body) {
return -1
}
return pos
}
func findSNIExtension(body []byte, pos int) string {
extLen := int(binary.BigEndian.Uint16(body[pos : pos+2]))
pos += 2
extEnd := pos + extLen
if extEnd > len(body) {
extEnd = len(body)
}
for pos+4 <= extEnd {
extType := binary.BigEndian.Uint16(body[pos : pos+2])
eLen := int(binary.BigEndian.Uint16(body[pos+2 : pos+4]))
pos += 4
if pos+eLen > extEnd {
break
}
if extType == 0 && eLen >= 5 {
nameLen := int(binary.BigEndian.Uint16(body[pos+3 : pos+5]))
if pos+5+nameLen <= extEnd {
return string(body[pos+5 : pos+5+nameLen])
}
}
pos += eLen
}
return ""
}
func isDNSPort(src, dst uint16) bool {
return src == 53 || dst == 53 || src == 5353 || dst == 5353
}
// formatDNSPayload parses DNS and returns a tcpdump-style summary.
func formatDNSPayload(payload []byte) string {
d := &layers.DNS{}
if err := d.DecodeFromBytes(payload, gopacket.NilDecodeFeedback); err != nil {
return ""
}
rd := ""
if d.RD {
rd = "+"
}
if !d.QR {
return formatDNSQuery(d, rd, len(payload))
}
return formatDNSResponse(d, rd, len(payload))
}
func formatDNSQuery(d *layers.DNS, rd string, plen int) string {
if len(d.Questions) == 0 {
return fmt.Sprintf("%04x%s (%d)", d.ID, rd, plen)
}
q := d.Questions[0]
return fmt.Sprintf("%04x%s %s? %s. (%d)", d.ID, rd, q.Type, q.Name, plen)
}
func formatDNSResponse(d *layers.DNS, rd string, plen int) string {
anCount := d.ANCount
nsCount := d.NSCount
arCount := d.ARCount
if d.ResponseCode != layers.DNSResponseCodeNoErr {
return fmt.Sprintf("%04x %d/%d/%d %s (%d)", d.ID, anCount, nsCount, arCount, d.ResponseCode, plen)
}
if anCount > 0 && len(d.Answers) > 0 {
rr := d.Answers[0]
if rdata := shortRData(&rr); rdata != "" {
return fmt.Sprintf("%04x %d/%d/%d %s %s (%d)", d.ID, anCount, nsCount, arCount, rr.Type, rdata, plen)
}
}
return fmt.Sprintf("%04x %d/%d/%d (%d)", d.ID, anCount, nsCount, arCount, plen)
}
func shortRData(rr *layers.DNSResourceRecord) string {
switch rr.Type {
case layers.DNSTypeA, layers.DNSTypeAAAA:
if rr.IP != nil {
return rr.IP.String()
}
case layers.DNSTypeCNAME:
if len(rr.CNAME) > 0 {
return string(rr.CNAME) + "."
}
case layers.DNSTypePTR:
if len(rr.PTR) > 0 {
return string(rr.PTR) + "."
}
case layers.DNSTypeNS:
if len(rr.NS) > 0 {
return string(rr.NS) + "."
}
case layers.DNSTypeMX:
return fmt.Sprintf("%d %s.", rr.MX.Preference, rr.MX.Name)
case layers.DNSTypeTXT:
if len(rr.TXTs) > 0 {
return fmt.Sprintf("%q", string(rr.TXTs[0]))
}
case layers.DNSTypeSRV:
return fmt.Sprintf("%d %d %d %s.", rr.SRV.Priority, rr.SRV.Weight, rr.SRV.Port, rr.SRV.Name)
}
return ""
}