internal/divert: WinDivert handle wrapper
Thin Go layer over imgk/divert-go. Exposes Open/Close/Recv/Send and maps the most relevant Windows errors to sentinels (ErrAccessDenied, ErrDriverFailedPriorUnload, ErrInvalidHandle, ErrShutdown) so the engine's recovery classifier can reason about them without importing golang.org/x/sys/windows. Verified imgk/divert-go@v0.1.0 API matches plan; only deviation is Recv/Send returning uint (cast to int at our boundary). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user