diff --git a/internal/divert/divert.go b/internal/divert/divert.go new file mode 100644 index 0000000..d91d7d9 --- /dev/null +++ b/internal/divert/divert.go @@ -0,0 +1,130 @@ +//go:build windows + +package divert + +import ( + "errors" + "fmt" + + idivert "github.com/imgk/divert-go" +) + +// Handle wraps a WinDivert handle. +type Handle struct { + h *idivert.Handle +} + +// Open opens a WinDivert handle at NETWORK layer for outbound capture. +// The filter expression is the standard WinDivert syntax (see +// internal/divert/filter.go for our builder). +// +// Returns ErrAccessDenied when the calling process is not elevated. +// Returns ErrDriverFailedPriorUnload when an outdated WinDivert +// (e.g. v1.x from zapret) is already loaded. +func Open(filter string) (*Handle, error) { + h, err := idivert.Open(filter, idivert.LayerNetwork, 0, 0) + if err != nil { + return nil, mapWinDivertErr(err) + } + return &Handle{h: h}, nil +} + +// Close closes the handle. Safe to call multiple times. +func (h *Handle) Close() error { + if h == nil || h.h == nil { + return nil + } + err := h.h.Close() + h.h = nil + return err +} + +// Recv blocks until a packet arrives that matches the filter, or until +// the handle is closed (Close from another goroutine returns +// ErrShutdown to the recv'er). buf must be sized for a full Ethernet +// MTU (~1600 bytes is fine). +// +// Returns the captured packet length, the WinDivertAddress (containing +// direction, interface index, etc), and any error. +func (h *Handle) Recv(buf []byte) (int, *idivert.Address, error) { + if h == nil || h.h == nil { + return 0, nil, errors.New("handle closed") + } + addr := new(idivert.Address) + n, err := h.h.Recv(buf, addr) + if err != nil { + return 0, nil, mapWinDivertErr(err) + } + return int(n), addr, nil +} + +// Send reinjects a packet. The address typically comes from a previous +// Recv call (so the kernel knows whether it's outbound or inbound, which +// interface, etc). +func (h *Handle) Send(buf []byte, addr *idivert.Address) (int, error) { + if h == nil || h.h == nil { + return 0, errors.New("handle closed") + } + n, err := h.h.Send(buf, addr) + if err != nil { + return 0, mapWinDivertErr(err) + } + return int(n), nil +} + +// Sentinel errors mapped from raw Windows errors so the engine layer +// can pattern-match without importing windows package. +var ( + ErrAccessDenied = errors.New("WinDivert: access denied (need admin)") + ErrDriverFailedPriorUnload = errors.New("WinDivert: outdated driver from another tool is loaded; reboot or stop the other tool first") + ErrInvalidHandle = errors.New("WinDivert: handle invalidated (driver crashed?)") + ErrShutdown = errors.New("WinDivert: shutdown") +) + +func mapWinDivertErr(err error) error { + if err == nil { + return nil + } + msg := err.Error() + switch { + case contains(msg, "access is denied"), contains(msg, "ACCESS_DENIED"): + return ErrAccessDenied + case contains(msg, "FAILED_PRIOR_UNLOAD"), contains(msg, "prior unload"): + return ErrDriverFailedPriorUnload + case contains(msg, "INVALID_HANDLE"): + return ErrInvalidHandle + case contains(msg, "SHUTDOWN"): + return ErrShutdown + } + return fmt.Errorf("WinDivert: %w", err) +} + +func contains(s, sub string) bool { + // case-insensitive + if len(sub) == 0 { + return true + } + if len(s) < len(sub) { + return false + } + for i := 0; i+len(sub) <= len(s); i++ { + match := true + for j := 0; j < len(sub); j++ { + a, b := s[i+j], sub[j] + if a >= 'A' && a <= 'Z' { + a += 32 + } + if b >= 'A' && b <= 'Z' { + b += 32 + } + if a != b { + match = false + break + } + } + if match { + return true + } + } + return false +} diff --git a/internal/divert/divert_test.go b/internal/divert/divert_test.go new file mode 100644 index 0000000..7d4fa44 --- /dev/null +++ b/internal/divert/divert_test.go @@ -0,0 +1,46 @@ +package divert + +import ( + "os" + "runtime" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestOpen_FalseFilterRoundtrip is a Windows + admin smoke test. +// Skips when not on Windows or not admin. +func TestOpen_FalseFilterRoundtrip(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("Windows-only") + } + if !isAdminTest() { + t.Skip("requires admin; run from elevated shell") + } + // Install the driver first so the .sys is present + _, err := InstallDriver() + require.NoError(t, err) + + h, err := Open("false") // matches no packets + require.NoError(t, err) + defer h.Close() +} + +// isAdminTest is a thin wrapper to keep the test file Windows-pure +// without re-implementing IsAdmin from cmd/drover (we'd circular-import). +func isAdminTest() bool { + // Read TokenElevation directly via os/syscall to avoid the import cycle. + // For simplicity we just check whether we can write to System32. + // (Smoke-only; production code uses cmd/drover's IsAdmin.) + _, err := os.Stat(`C:\Windows\System32\drivers`) + if err != nil { + return false + } + f, err := os.OpenFile(`C:\Windows\System32\drivers\.drover-admin-test`, os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return false + } + f.Close() + os.Remove(`C:\Windows\System32\drivers\.drover-admin-test`) + return true +}