mirror of
https://github.com/SagerNet/sing-box.git
synced 2026-07-19 21:08:40 +08:00
221 lines
7.4 KiB
Go
221 lines
7.4 KiB
Go
//go:build windows
|
|
|
|
package windivert
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"log"
|
|
"net/netip"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/sagernet/sing-box/internal/winmutex"
|
|
E "github.com/sagernet/sing/common/exceptions"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
"golang.org/x/sys/windows"
|
|
)
|
|
|
|
func TestMain(m *testing.M) {
|
|
exitCode, err := winmutex.WithLock("SingBoxWinDivertIntegrationTests", 3*time.Minute, func() (int, error) {
|
|
return m.Run(), nil
|
|
})
|
|
if err != nil {
|
|
log.Print(E.Cause(err, "run in exclusive WinDivert integration test environment"))
|
|
os.Exit(1)
|
|
}
|
|
os.Exit(exitCode)
|
|
}
|
|
|
|
func openHandle(t *testing.T, filter *Filter, flags Flag) *Handle {
|
|
t.Helper()
|
|
h, err := Open(filter, LayerNetwork, 0, flags)
|
|
require.NoError(t, err)
|
|
return h
|
|
}
|
|
|
|
// A send-only handle installs+opens the driver but does not attach a
|
|
// receive filter, so it exercises the full driver-install path without
|
|
// diverting any live traffic on the host.
|
|
func TestIntegrationOpenSendOnly(t *testing.T) {
|
|
h := openHandle(t, nil, FlagSendOnly)
|
|
require.NoError(t, h.Close())
|
|
}
|
|
|
|
// Close is idempotent per the doc contract.
|
|
func TestIntegrationCloseTwice(t *testing.T) {
|
|
h := openHandle(t, nil, FlagSendOnly)
|
|
require.NoError(t, h.Close())
|
|
require.NoError(t, h.Close())
|
|
}
|
|
|
|
// Recv must unblock when the handle is closed concurrently. Without this,
|
|
// the spoofer's run goroutine could deadlock on shutdown.
|
|
func TestIntegrationRecvAbortsOnClose(t *testing.T) {
|
|
// A filter no live traffic will match, so Recv blocks indefinitely
|
|
// until Close aborts the overlapped I/O.
|
|
filter, err := OutboundTCP(
|
|
netip.MustParseAddrPort("10.255.255.254:1"),
|
|
netip.MustParseAddrPort("10.255.255.253:2"),
|
|
)
|
|
require.NoError(t, err)
|
|
h := openHandle(t, filter, 0)
|
|
|
|
errCh := make(chan error, 1)
|
|
go func() {
|
|
buf := make([]byte, MTUMax)
|
|
_, _, recvErr := h.Recv(buf)
|
|
errCh <- recvErr
|
|
}()
|
|
|
|
// Let Recv reach the blocking DeviceIoControl before Close races in.
|
|
time.Sleep(200 * time.Millisecond)
|
|
require.NoError(t, h.Close())
|
|
|
|
select {
|
|
case err := <-errCh:
|
|
require.Error(t, err)
|
|
require.True(t, errors.Is(err, windows.ERROR_OPERATION_ABORTED),
|
|
"Recv should return ERROR_OPERATION_ABORTED, got %v", err)
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("Recv did not unblock within 3s after Close")
|
|
}
|
|
}
|
|
|
|
func cachedDriverPath(t *testing.T) string {
|
|
t.Helper()
|
|
base, err := os.UserCacheDir()
|
|
require.NoError(t, err)
|
|
return filepath.Join(base, "sing-box", "windivert", "v"+AssetVersion, driverSysName())
|
|
}
|
|
|
|
// The driver does not unload when the last handle closes: it stays running
|
|
// (and the memory manager keeps its backing image write-locked) until
|
|
// explicitly stopped, like `sc stop WinDivert`. The install-time
|
|
// DeleteService mark then removes the record once the last SCM handle
|
|
// closes.
|
|
func stopDriver(t *testing.T) {
|
|
t.Helper()
|
|
manager, err := windows.OpenSCManager(nil, nil, windows.SC_MANAGER_CONNECT)
|
|
require.NoError(t, err)
|
|
defer windows.CloseServiceHandle(manager)
|
|
serviceNameW, err := windows.UTF16PtrFromString(driverServiceName)
|
|
require.NoError(t, err)
|
|
service, err := windows.OpenService(manager, serviceNameW, windows.SERVICE_STOP|windows.SERVICE_QUERY_STATUS)
|
|
if err == nil {
|
|
defer windows.CloseServiceHandle(service)
|
|
var status windows.SERVICE_STATUS
|
|
err = windows.ControlService(service, windows.SERVICE_CONTROL_STOP, &status)
|
|
if err != nil &&
|
|
!errors.Is(err, windows.ERROR_SERVICE_NOT_ACTIVE) &&
|
|
!errors.Is(err, windows.ERROR_SERVICE_CANNOT_ACCEPT_CTRL) {
|
|
require.NoError(t, err)
|
|
}
|
|
require.Eventually(t, func() bool {
|
|
queryErr := windows.QueryServiceStatus(service, &status)
|
|
return queryErr == nil && status.CurrentState == windows.SERVICE_STOPPED
|
|
}, 60*time.Second, 200*time.Millisecond, "driver did not reach SERVICE_STOPPED")
|
|
} else {
|
|
require.True(t, errors.Is(err, windows.ERROR_SERVICE_DOES_NOT_EXIST), "open driver service: %v", err)
|
|
}
|
|
// SCM can report SERVICE_STOPPED before the driver finishes deleting its
|
|
// device object. Wait for the absence acquireDevice uses to trigger install.
|
|
require.Eventually(t, func() bool {
|
|
device, openErr := openDevice()
|
|
if openErr == nil {
|
|
_ = windows.CloseHandle(device)
|
|
return false
|
|
}
|
|
return errors.Is(openErr, windows.ERROR_FILE_NOT_FOUND) ||
|
|
errors.Is(openErr, windows.ERROR_PATH_NOT_FOUND) ||
|
|
errors.Is(openErr, windows.ERROR_NO_SUCH_DEVICE)
|
|
}, 60*time.Second, 200*time.Millisecond, "driver device remained openable after stop")
|
|
}
|
|
|
|
// The image lock on the cached .sys can outlive SERVICE_STOPPED by tens of
|
|
// seconds (observed on GitHub-hosted runners), and on current runner images
|
|
// it blocks renames as well as writes and deletes. Tests that need to tamper
|
|
// with the cache therefore redirect it to a directory the kernel has never
|
|
// loaded a driver from. Not t.TempDir: once StartService maps a .sys from
|
|
// the directory, the image lock makes the cleanup RemoveAll fail the test.
|
|
func setTempDriverCache(t *testing.T) {
|
|
t.Helper()
|
|
dir, err := os.MkdirTemp("", "sing-box-windivert-test-")
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { os.RemoveAll(dir) })
|
|
t.Setenv("LocalAppData", dir)
|
|
}
|
|
|
|
// A foreign .sys planted in the user-writable cache must never reach
|
|
// StartService: the install path has to detect the mismatch against the
|
|
// embedded asset and repair the file before handing it to SCM.
|
|
func TestIntegrationTamperedCacheRepaired(t *testing.T) {
|
|
setTempDriverCache(t)
|
|
// The driver left running by earlier tests would satisfy Open without
|
|
// touching the cache; stop it so the install path runs.
|
|
stopDriver(t)
|
|
|
|
target := cachedDriverPath(t)
|
|
require.NoError(t, os.MkdirAll(filepath.Dir(target), 0o755))
|
|
require.NoError(t, os.WriteFile(target, []byte("planted payload, not the WinDivert driver"), 0o644))
|
|
|
|
h := openHandle(t, nil, FlagSendOnly)
|
|
require.NoError(t, h.Close())
|
|
|
|
content, err := os.ReadFile(target)
|
|
require.NoError(t, err)
|
|
require.True(t, bytes.Equal(content, sysBytes), "cached driver was not repaired to the embedded asset")
|
|
}
|
|
|
|
// The verified handle must lock the file against writers and renames until
|
|
// install completes; without this, the file could be swapped between
|
|
// verification and the kernel mapping it.
|
|
func TestIntegrationDriverFileLockedWhileHeld(t *testing.T) {
|
|
// A fresh cache directory guarantees the failures asserted below can
|
|
// only come from the handle extractVerified holds, not a kernel image
|
|
// lock left by earlier tests.
|
|
setTempDriverCache(t)
|
|
|
|
sysPath, sysFile, err := extractVerified()
|
|
require.NoError(t, err)
|
|
defer sysFile.Close()
|
|
|
|
writeErr := os.WriteFile(sysPath, []byte("overwrite attempt"), 0o644)
|
|
require.Error(t, writeErr)
|
|
require.True(t, errors.Is(writeErr, windows.ERROR_SHARING_VIOLATION),
|
|
"expected sharing violation, got %v", writeErr)
|
|
|
|
evil := sysPath + ".evil"
|
|
require.NoError(t, os.WriteFile(evil, []byte("replacement attempt"), 0o644))
|
|
defer os.Remove(evil)
|
|
renameErr := os.Rename(evil, sysPath)
|
|
require.Error(t, renameErr)
|
|
}
|
|
|
|
// Two concurrent Open calls must both succeed: the first wins the driver
|
|
// install race, the second reuses the already-running service.
|
|
func TestIntegrationConcurrentOpen(t *testing.T) {
|
|
stopDriver(t)
|
|
start := make(chan struct{})
|
|
errCh := make(chan error, 2)
|
|
handles := make(chan *Handle, 2)
|
|
for range 2 {
|
|
go func() {
|
|
<-start
|
|
h, err := Open(nil, LayerNetwork, 0, FlagSendOnly)
|
|
handles <- h
|
|
errCh <- err
|
|
}()
|
|
}
|
|
close(start)
|
|
for range 2 {
|
|
err := <-errCh
|
|
h := <-handles
|
|
require.NoError(t, err)
|
|
require.NoError(t, h.Close())
|
|
}
|
|
}
|